Compare commits

...
Author SHA1 Message Date
Thomas 99e013c1fb Merge branch 'dev' into feat/widget-overlay 2026-06-10 16:54:25 +02:00
homarr-renovate[bot] b3cdab579b chore(deps): update dependency isomorphic-dompurify to ^3.16.0 (#5913)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-10 14:31:02 +00:00
homarr-renovate[bot] 44a0c6ce54 chore(deps): update dependency semver-parser to ^4.2.1 (#5868)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 12:47:00 +00:00
homarr-renovate[bot] 7f05e0325d chore(deps): update dependency rollup@>=4.0.0 <4.22.4 to >=4.61.1 (#5906)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 09:53:16 +00:00
homarr-renovate[bot] f71b145403 chore(deps): update tiptap monorepo to v3.25.0 (#5898)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 05:17:18 +00:00
homarr-renovate[bot] 607fd498f4 chore(deps): update tanstack-query monorepo to ^5.101.0 (#5894)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-09 00:17:40 +00:00
homarr-renovate[bot] 3f2fb54cdf chore(deps): update mantine monorepo to ^9.3.0 (#5890)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-08 22:39:48 +00:00
Thomasandhomarr-releases[bot] 69412f2d10 chore(release): 1.64.0 [skip ci] (#5902)
Co-authored-by: homarr-releases[bot] <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-08 22:25:40 +02:00
Thomas Camlong 786ad347a0 style(board): round overlay on all sides and hide on mobile
Apply full border-radius to hover overlay panel instead of bottom-only,
and add media query to hide the overlay on screens <= 768px since hover
is not available on touch devices.
2026-06-08 22:23:41 +02:00
Afonso Jorge Ramos f6d5f97933 fix(integrations): support truenas json-rpc api, certificate trust, and api key auth (#5861)
* chore(integrations): add ws dependency for the truenas client

* feat(definitions): allow api key authentication for truenas

* fix(integrations): connect to truenas over trusted-certificate websocket with protocol autodetection

* test(integrations): cover truenas protocol detection and authentication

* refactor(integrations): avoid shorthand index variable in truenas mapping

* feat(integrations): add a websocket url helper to the integration base

* refactor(integrations): extract truenas connection handling into a client
2026-06-08 22:20:42 +02:00
homarr-renovate[bot] a447a9f615 chore(deps): update dependency axios@>=1.0.0 <1.8.2 to >=1.17.0 (#5897)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-08 14:15:50 +00:00
homarr-renovate[bot] 87d3f6a0a1 chore(deps): update dependency @gfazioli/mantine-onboarding-tour to ^4.0.4 (#5896)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-08 09:30:40 +00:00
Thomas Camlong 93ce4e4a1b perf(board): lift integration query to SectionContent
Avoid N identical useQuery hooks by fetching integrations once
at the parent and passing them as a prop to each WidgetHoverOverlay.
2026-06-07 10:40:35 +02:00
ajnart 4cf41f2c4c style: auto-fix lint and format 2026-06-07 00:06:03 +00:00
Thomas Camlong ab3c8b4d1f feat(board): mount widget hover overlay on dashboard grid items 2026-06-07 02:05:01 +02:00
Thomas Camlong 78ddc81b15 feat(board): add widget hover overlay component 2026-06-07 02:04:57 +02:00
Thomas Camlong ad98f2d262 feat(widgets): export widget option types for hover metadata 2026-06-07 02:04:52 +02:00
homarr-renovate[bot] f2705eeb57 chore(deps): update dependency oxfmt to ^0.53.0 (#5883)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 23:12:23 +00:00
homarr-renovate[bot] ac37d2c73f chore(deps): update react monorepo (#5886)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 21:13:36 +00:00
homarr-renovate[bot] e00ea624cd chore(deps): update nextjs monorepo to v16.2.7 (#5885)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 19:32:30 +00:00
homarr-renovate[bot] 20b62c42e3 chore(deps): update dependency rollup@>=4.0.0 <4.22.4 to >=4.61.0 (#5878)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 13:54:21 +00:00
homarr-renovate[bot] 59971991c7 chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.0.16 (#5880)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 11:36:28 +00:00
homarr-renovate[bot] a629681ae5 chore(deps): update vitest monorepo to ^4.1.8 (#5879)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 09:34:48 +00:00
homarr-renovate[bot] 572d5fb5ec chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.0.15 (#5877)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-06 07:01:54 +00:00
Thomasandhomarr-releases[bot] 91a1858743 chore: merge main into dev
Co-authored-by: homarr-releases[bot] <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-06 07:47:59 +02:00
homarr-renovate[bot] f00d3627c9 chore(deps): update dependency @gfazioli/mantine-onboarding-tour to ^4.0.3 (#5873)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-05 18:04:10 +00:00
homarr-releases[bot] 7760e01ccb chore: update bug report template (#5821)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-05 17:47:57 +00:00
18 changed files with 2328 additions and 1385 deletions

No files matched your search

+1
View File
@@ -33,6 +33,7 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.64.0
- 1.62.0
- 1.61.0
- 1.60.1-beta.1
@@ -0,0 +1,155 @@
import { objectEntries } from "@homarr/common";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { SettingsContextProps } from "@homarr/settings/creator";
import type { WidgetOptionDefinition, WidgetOptionType } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
export type Translator = {
(key: string): string;
};
export interface HoverMetadataItem {
label: string;
value: string;
}
const maxMetadataItems = 3;
const skippedOptionTypes: Partial<Record<WidgetOptionType, true>> = {
app: true,
anchorNote: true,
dynamicSelect: true,
sortableItemList: true,
multiReleasesRepositories: true,
umamiEventName: true,
umamiEventNames: true,
umamiWebsite: true,
};
const isEqualValue = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right);
type OptionDefinitionWithRules = WidgetOptionDefinition & {
shouldHide?: (options: never, integrationKinds: IntegrationKind[]) => boolean;
options?: readonly { value: unknown; label: unknown }[];
};
type OptionFormatter = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => string | null;
const resolveSelectLabel = (value: unknown, definition: OptionDefinitionWithRules, t: Translator) => {
const match = definition.options?.find((option) => option.value === value);
const label = match?.label;
const fallbackLabel = () => String(value);
const labelResolvers: Record<string, () => string> = {
function: () => (label as (translator: Translator) => string)(t),
string: () => label as string,
missing: fallbackLabel,
};
const labelType = typeof label;
const resolveLabel: () => string = labelResolvers[labelType] ?? fallbackLabel;
return resolveLabel();
};
const optionTypeFormatters: Partial<Record<WidgetOptionType, OptionFormatter>> = {
switch: (value, _, t) => {
const switchLabels: Record<string, string> = {
true: t("common.action.show"),
false: t("common.action.hide"),
};
return switchLabels[String(Boolean(value))] ?? null;
},
text: (value) => {
const text = String(value).trim();
return text.length > 0 ? text : null;
},
number: (value) => String(value),
slider: (value) => String(value),
select: (value, definition, t) => resolveSelectLabel(value, definition, t),
multiSelect: (value) => {
const items = value as unknown[];
return items.length > 0 ? String(items.length) : null;
},
multiText: (value) => {
const items = (value as string[]).filter((entry) => entry.trim().length > 0);
return items.length > 0 ? items.join(", ") : null;
},
location: (value) => {
const location = value as { name?: string };
const name = location.name?.trim() ?? "";
return name.length > 0 ? name : null;
},
};
const formatOptionValue = (
type: WidgetOptionType,
value: unknown,
definition: OptionDefinitionWithRules,
t: Translator,
) => {
const formatter = optionTypeFormatters[type];
return formatter?.(value, definition, t) ?? null;
};
const getOptionLabel = (kind: WidgetKind, key: string, t: Translator) => {
const labelKey = `widget.${kind}.option.${key}.label`;
const translated = t(labelKey);
return translated === labelKey ? key : translated;
};
export const collectWidgetHoverMetadata = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
integrationKinds: IntegrationKind[],
t: Translator,
): HoverMetadataItem[] => {
const { definition } = widgetImports[item.kind];
const optionDefinitions = definition.createOptions(settings) as Record<string, OptionDefinitionWithRules>;
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
return objectEntries(optionDefinitions)
.filter(([key, optionDefinition]) => {
const skip = skippedOptionTypes[optionDefinition.type];
const hidden = optionDefinition.shouldHide?.(resolvedOptions as never, integrationKinds);
const unchanged = isEqualValue(resolvedOptions[key], optionDefinition.defaultValue);
return !skip && !hidden && !unchanged;
})
.map(([key, optionDefinition]) => {
const formattedValue = formatOptionValue(optionDefinition.type, resolvedOptions[key], optionDefinition, t);
return {
label: getOptionLabel(item.kind, key, t),
value: formattedValue ?? "",
};
})
.filter((entry) => entry.value.length > 0)
.slice(0, maxMetadataItems);
};
interface DisplayNameSource {
matches: (item: SectionItem, options: Record<string, unknown>) => boolean;
resolve: (item: SectionItem, options: Record<string, unknown>) => string;
}
const displayNameSources: DisplayNameSource[] = [
{
matches: (item) => Boolean(item.advancedOptions.title?.trim()),
resolve: (item) => item.advancedOptions.title?.trim() ?? "",
},
{
matches: (item, options) =>
item.kind === "clock" &&
Boolean(options.customTitleToggle) &&
String(options.customTitle ?? "").trim().length > 0,
resolve: (_, options) => String(options.customTitle).trim(),
},
];
export const resolveWidgetDisplayName = (
item: SectionItem,
settings: Pick<SettingsContextProps, "enableStatusByDefault" | "forceDisableStatus">,
t: Translator,
) => {
const resolvedOptions = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
const source = displayNameSources.find((entry) => entry.matches(item, resolvedOptions));
return source?.resolve(item, resolvedOptions) ?? t(`widget.${item.kind}.name`);
};
@@ -0,0 +1,116 @@
.wrapper {
position: absolute;
left: 10px;
right: 10px;
bottom: calc(100% - 10px);
margin-bottom: 1px;
z-index: 200;
pointer-events: auto;
opacity: 0;
transform: translateY(6px);
transition:
opacity 150ms ease,
transform 150ms ease;
}
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
opacity: 1;
transform: translateY(0);
}
.panel {
@mixin dark {
--background-color: rgb(from var(--mantine-color-dark-7) r g b / 0.96);
--border-color: rgb(from var(--mantine-color-dark-4) r g b / 0.9);
}
@mixin light {
--background-color: rgb(from var(--mantine-color-white) r g b / 0.96);
--border-color: rgb(from var(--mantine-color-gray-3) r g b / 0.9);
}
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: var(--mantine-radius-md);
background-color: var(--background-color);
box-shadow: 0 4px 16px rgb(0 0 0 / 0.12);
backdrop-filter: blur(8px);
overflow: hidden;
}
.nameSection {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-shrink: 0;
}
.widgetIcon {
flex-shrink: 0;
color: var(--mantine-color-dimmed);
}
.name {
font-size: 11px;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.integrations {
flex-shrink: 0;
}
.integrationAvatar {
border: none;
}
.metadata {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
margin-left: auto;
}
.metadataItem {
@mixin dark {
--chip-background: rgb(from var(--mantine-color-dark-5) r g b / 0.9);
}
@mixin light {
--chip-background: rgb(from var(--mantine-color-gray-1) r g b / 0.95);
}
display: flex;
align-items: center;
gap: 4px;
max-width: 140px;
padding: 2px 6px;
border-radius: var(--mantine-radius-md);
background-color: var(--chip-background);
font-size: 10px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
}
.metadataLabel {
color: var(--mantine-color-dimmed);
flex-shrink: 0;
}
.metadataValue {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 768px) {
.wrapper {
display: none;
}
}
@@ -0,0 +1,86 @@
"use client";
import { Avatar, Group, Text, Tooltip } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getIconUrl } from "@homarr/definitions";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { widgetImports } from "@homarr/widgets";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import type { Translator } from "./widget-hover-metadata";
import { collectWidgetHoverMetadata, resolveWidgetDisplayName } from "./widget-hover-metadata";
import classes from "./widget-hover-overlay.module.css";
interface WidgetHoverOverlayProps {
item: SectionItem;
integrations: RouterOutputs["integration"]["all"] | undefined;
}
export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProps) => {
const [isEditMode] = useEditMode();
const settings = useSettings();
const t = useI18n();
const { definition } = widgetImports[item.kind];
const WidgetIcon = definition.icon;
const translate = ((key: string) => t(key as never)) as Translator;
const displayName = resolveWidgetDisplayName(item, settings, translate);
const connectedIntegrations = (integrations ?? []).filter((integration) =>
item.integrationIds.includes(integration.id),
);
const metadataItems = collectWidgetHoverMetadata(
item,
settings,
connectedIntegrations.map((integration) => integration.kind),
translate,
);
const hasIntegrations = connectedIntegrations.length > 0;
const hasMetadata = metadataItems.length > 0;
if (isEditMode) {
return null;
}
return (
<div className={classes.wrapper}>
<div className={classes.panel}>
<div className={classes.nameSection}>
<WidgetIcon size={14} stroke={1.75} className={classes.widgetIcon} />
<Text className={classes.name} title={displayName}>
{displayName}
</Text>
</div>
{hasIntegrations && (
<Avatar.Group spacing="xs" className={classes.integrations}>
{connectedIntegrations.map((integration) => (
<Tooltip key={integration.id} label={integration.name} withArrow position="top">
<Avatar
src={getIconUrl(integration.kind)}
size={20}
radius="sm"
className={classes.integrationAvatar}
/>
</Tooltip>
))}
</Avatar.Group>
)}
{hasMetadata && (
<Group className={classes.metadata} gap={4}>
{metadataItems.map((metadataItem) => (
<div key={`${metadataItem.label}-${metadataItem.value}`} className={classes.metadataItem}>
<span className={classes.metadataLabel}>{metadataItem.label}</span>
<span className={classes.metadataValue}>{metadataItem.value}</span>
</div>
))}
</Group>
)}
</div>
</div>
);
};
@@ -2,8 +2,12 @@ import { useMemo } from "react";
import type { GridItemHTMLElement } from "@homarr/gridstack";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { DynamicSectionItem, SectionItem } from "~/app/[locale]/boards/_types";
import { BoardItemContent } from "../items/item-content";
import { WidgetHoverOverlay } from "../items/widget-hover-overlay";
import { BoardDynamicSection } from "./dynamic-section";
import { GridStackItem } from "./gridstack/gridstack-item";
import { useSectionContext } from "./section-context";
@@ -11,6 +15,7 @@ import { useSectionItems } from "./use-section-items";
export const SectionContent = () => {
const { innerSections, items, refs } = useSectionContext();
const { data: integrations } = clientApi.integration.all.useQuery();
/**
* IMPORTANT: THE ORDER OF THE BELOW ITEMS HAS TO MATCH THE ORDER OF
@@ -18,7 +23,7 @@ export const SectionContent = () => {
* @see https://github.com/homarr-labs/homarr/pull/1770
*/
const sortedItems = useMemo(() => {
return [...items, ...innerSections].sort((itemA, itemB) => {
return [...items, ...innerSections].toSorted((itemA, itemB) => {
if (itemA.yOffset === itemB.yOffset) {
return itemA.xOffset - itemB.xOffset;
}
@@ -30,7 +35,7 @@ export const SectionContent = () => {
return (
<>
{sortedItems.map((item) => (
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} />
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} integrations={integrations} />
))}
</>
);
@@ -39,12 +44,12 @@ export const SectionContent = () => {
interface ItemProps {
item: DynamicSectionItem | SectionItem;
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
integrations: RouterOutputs["integration"]["all"] | undefined;
}
const Item = ({ item, innerRef }: ItemProps) => {
const Item = ({ item, innerRef, integrations }: ItemProps) => {
const minWidth = useMinSize(item, "x");
const minHeight = useMinSize(item, "y");
return (
<GridStackItem
key={item.id}
@@ -59,6 +64,7 @@ const Item = ({ item, innerRef }: ItemProps) => {
minWidth={minWidth}
minHeight={minHeight}
>
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardDynamicSection section={item} />}
</GridStackItem>
);
+4
View File
@@ -102,6 +102,10 @@
overflow-y: auto;
}
.grid-stack > .grid-stack-item[data-type="item"] {
overflow: visible;
}
.grid-stack.grid-stack-animate {
transition: none;
}
+4 -4
View File
@@ -78,7 +78,7 @@
"overrides": {
"@babel/helpers@<7.26.10": ">=7.29.7",
"@babel/runtime@<7.26.10": ">=7.29.7",
"axios@>=1.0.0 <1.8.2": ">=1.16.1",
"axios@>=1.0.0 <1.8.2": ">=1.17.0",
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
"esbuild@<=0.24.2": ">=0.28.0",
@@ -88,13 +88,13 @@
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
"prismjs@<1.30.0": ">=1.30.0",
"proxmox-api>undici": "7.24.6",
"react-is": "^19.2.6",
"rollup@>=4.0.0 <4.22.4": ">=4.60.4",
"react-is": "^19.2.7",
"rollup@>=4.0.0 <4.22.4": ">=4.61.1",
"sha.js@<=2.4.11": ">=2.4.12",
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
"tmp@<=0.2.3": ">=0.2.7",
"vite@>=5.0.0 <=5.4.18": ">=8.0.14"
"vite@>=5.0.0 <=5.4.18": ">=8.0.16"
},
"patchedDependencies": {
"@types/node-unifi": "patches/@types__node-unifi.patch",
+1 -1
View File
@@ -364,7 +364,7 @@ export const integrationDefs = {
},
truenas: {
name: "TrueNAS",
secretKinds: [["username", "password"]],
secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
+2
View File
@@ -47,12 +47,14 @@
"timezones-ical-library": "catalog:",
"tsdav": "catalog:",
"undici": "catalog:",
"ws": "catalog:",
"xml2js": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-unifi": "catalog:",
"@types/ws": "catalog:",
"@types/xml2js": "catalog:",
"typescript": "catalog:"
}
@@ -85,6 +85,16 @@ export abstract class Integration {
return this.createUrl(this.integration.externalUrl ?? this.integration.url, path, queryParams);
}
protected webSocketUrl(
path: `/${string}`,
queryParams?: Record<string, string | Date | number | boolean | null | undefined>,
) {
const url = this.url(path, queryParams);
// http -> ws, https -> wss
url.protocol = url.protocol.replace("http", "ws");
return url;
}
public async testConnectionAsync(): Promise<TestingResult> {
try {
const url = new URL(this.integration.url);
@@ -0,0 +1,79 @@
import { describe, expect, test } from "vitest";
import { trueNasApis } from "../truenas-api";
describe("trueNasApis descriptors", () => {
test("expose the expected endpoints and handshake requirements", () => {
expect(trueNasApis.jsonRpc).toMatchObject({
kind: "jsonRpc",
path: "/api/current",
requiresSessionHandshake: false,
});
expect(trueNasApis.legacy).toMatchObject({
kind: "legacy",
path: "/websocket",
requiresSessionHandshake: true,
});
});
});
describe("jsonRpc api", () => {
const { buildRequest, matchResponse } = trueNasApis.jsonRpc;
test("builds a JSON-RPC 2.0 envelope", () => {
expect(buildRequest("id-1", "system.info", [])).toStrictEqual({
jsonrpc: "2.0",
id: "id-1",
method: "system.info",
params: [],
});
});
test("matches a result for the requested id", () => {
const result = matchResponse({ jsonrpc: "2.0", id: "id-1", result: true }, "id-1");
expect(result).toStrictEqual({ result: true });
});
test("returns the error when the response carries one", () => {
const error = { code: -32001, message: "Not authenticated" };
expect(matchResponse({ jsonrpc: "2.0", id: "id-1", error }, "id-1")).toStrictEqual({ error });
});
test("ignores responses for a different id", () => {
expect(matchResponse({ jsonrpc: "2.0", id: "other", result: true }, "id-1")).toBeUndefined();
});
test("ignores non JSON-RPC messages", () => {
expect(matchResponse({ msg: "result", id: "id-1", result: true }, "id-1")).toBeUndefined();
});
});
describe("legacy api", () => {
const { buildRequest, matchResponse } = trueNasApis.legacy;
test("builds a DDP method envelope", () => {
expect(buildRequest("id-1", "auth.login", ["user", "pass"])).toStrictEqual({
id: "id-1",
msg: "method",
method: "auth.login",
params: ["user", "pass"],
});
});
test("matches a result message for the requested id", () => {
expect(matchResponse({ msg: "result", id: "id-1", result: false }, "id-1")).toStrictEqual({ result: false });
});
test("returns the error when the result message carries one", () => {
const error = { error: 13, reason: "boom" };
expect(matchResponse({ msg: "result", id: "id-1", error }, "id-1")).toStrictEqual({ error });
});
test("ignores responses for a different id", () => {
expect(matchResponse({ msg: "result", id: "other", result: true }, "id-1")).toBeUndefined();
});
test("ignores non-result messages such as the session handshake", () => {
expect(matchResponse({ msg: "connected", session: "abc" }, "id-1")).toBeUndefined();
});
});
@@ -0,0 +1,264 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { IntegrationRequestError } from "../../base/errors/http/integration-request-error";
import type { IntegrationSecret } from "../../base/types";
const ws = vi.hoisted(() => {
const sentPayloads: Record<string, unknown>[] = [];
const constructedUrls: string[] = [];
// A failure with a `code` emits a coded TLS-style error; without one it mimics a missing endpoint (HTTP 404).
const failures: { path: string; code?: string }[] = [];
let responder: (method: string, params: unknown[]) => unknown = () => undefined;
type Listener = (...args: unknown[]) => void;
class FakeWebSocket {
static readonly OPEN = 1;
public readyState = FakeWebSocket.OPEN;
private readonly listeners: Record<string, Listener[]> = {};
constructor(url: URL | string) {
constructedUrls.push(url.toString());
setTimeout(() => {
const failure = failures.find((candidate) => url.toString().includes(candidate.path));
if (failure) {
const error: Error & { code?: string } = new Error(failure.code ?? "Unexpected server response: 404");
if (failure.code) error.code = failure.code;
this.dispatch("error", error);
} else {
this.dispatch("open");
}
}, 0);
}
on(event: string, listener: Listener) {
(this.listeners[event] ??= []).push(listener);
return this;
}
once(event: string, listener: Listener) {
const wrapper: Listener = (...args) => {
this.off(event, wrapper);
listener(...args);
};
return this.on(event, wrapper);
}
off(event: string, listener: Listener) {
this.listeners[event] = (this.listeners[event] ?? []).filter((candidate) => candidate !== listener);
return this;
}
send(raw: string) {
const payload = JSON.parse(raw) as Record<string, unknown>;
sentPayloads.push(payload);
if (payload.msg === "connect") {
this.respond(JSON.stringify({ msg: "connected", session: "test-session" }));
return;
}
const result = responder(payload.method as string, (payload.params as unknown[] | undefined) ?? []);
const envelope =
payload.jsonrpc === "2.0"
? { jsonrpc: "2.0", id: payload.id, result }
: { msg: "result", id: payload.id, result };
this.respond(JSON.stringify(envelope));
}
close() {
this.readyState = 3;
}
private respond(message: string) {
setTimeout(() => this.dispatch("message", message), 0);
}
private dispatch(event: string, ...args: unknown[]) {
// `off` replaces the array rather than mutating it, so iterating it directly is safe.
for (const listener of this.listeners[event] ?? []) listener(...args);
}
}
return {
FakeWebSocket,
sentPayloads,
constructedUrls,
failures,
setResponder: (fn: (method: string, params: unknown[]) => unknown) => {
responder = fn;
},
};
});
vi.mock("ws", () => ({ WebSocket: ws.FakeWebSocket }));
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
}));
vi.mock("@homarr/core/infrastructure/http", () => ({
createCustomCheckServerIdentity: () => () => undefined,
}));
import { TrueNasIntegration } from "../truenas-integration";
const happyResponder = (method: string) => {
switch (method) {
case "auth.login":
case "auth.login_with_api_key":
return true;
case "system.info":
return {
version: "TrueNAS-SCALE-24.10",
hostname: "nas",
physmem: 16_000_000_000,
model: "Intel Xeon",
uptime_seconds: 3600,
};
case "reporting.get_data":
return [
{
name: "cpu",
identifier: "cpu",
aggregations: emptyAggregations(),
start: 0,
end: 1,
legend: [],
data: [[1000, 12]],
},
{
name: "memory",
identifier: "memory",
aggregations: emptyAggregations(),
start: 0,
end: 1,
legend: [],
data: [[1000, 8_000_000_000]],
},
{
name: "cputemp",
identifier: "cputemp",
aggregations: emptyAggregations(),
start: 0,
end: 1,
legend: [],
data: [[1000, 45]],
},
];
case "pool.query":
return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }];
case "interface.query":
return [{ id: "eth0", name: "eth0" }];
case "reporting.netdata_get_data":
return [{ name: "interface", identifier: "eth0", data: [[1000, 100, 200]] }];
default:
return null;
}
};
const emptyAggregations = () => ({ min: {}, mean: {}, max: {} });
let integrationCounter = 0;
const createIntegration = (decryptedSecrets: IntegrationSecret[]) =>
new TrueNasIntegration({
id: `truenas-${(integrationCounter += 1)}`,
name: "TrueNAS Test",
url: "https://truenas.local",
externalUrl: null,
decryptedSecrets,
});
const credentials: IntegrationSecret[] = [
{ kind: "username", value: "root" },
{ kind: "password", value: "secret" },
];
describe("TrueNasIntegration", () => {
beforeEach(() => {
ws.sentPayloads.length = 0;
ws.constructedUrls.length = 0;
ws.failures.length = 0;
ws.setResponder(happyResponder);
});
test("fetches and maps system info over the JSON-RPC API", async () => {
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(ws.constructedUrls).toContain("wss://truenas.local/api/current");
// JSON-RPC does not use the legacy "connect" session handshake.
expect(ws.sentPayloads.some((payload) => payload.msg === "connect")).toBe(false);
expect(result).toMatchObject({
version: "TrueNAS-SCALE-24.10",
cpuModelName: "Intel Xeon",
cpuTemp: 45,
memAvailableInBytes: 16_000_000_000,
memUsedInBytes: 8_000_000_000,
uptime: 3600,
rebootRequired: false,
availablePkgUpdates: 0,
loadAverage: null,
gpu: [],
network: { up: 20_000, down: 10_000 },
fileSystem: [{ deviceName: "tank", available: "1000", used: "500", percentage: 50 }],
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
});
});
test("authenticates with an API key when one is configured", async () => {
const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]);
await integration.getSystemInfoAsync();
const authPayload = ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth."));
expect(authPayload).toMatchObject({ method: "auth.login_with_api_key", params: ["api-key-123"] });
});
test("authenticates with username and password when no API key is configured", async () => {
const integration = createIntegration(credentials);
await integration.getSystemInfoAsync();
const authPayload = ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth."));
expect(authPayload).toMatchObject({ method: "auth.login", params: ["root", "secret"] });
});
test("falls back to the legacy websocket API when the JSON-RPC endpoint is unavailable", async () => {
ws.failures.push({ path: "/api/current" });
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(ws.constructedUrls).toContain("wss://truenas.local/api/current");
expect(ws.constructedUrls).toContain("wss://truenas.local/websocket");
// The legacy API requires the "connect" session handshake before authenticating.
expect(ws.sentPayloads[0]).toMatchObject({ msg: "connect" });
expect(ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth."))).toMatchObject({
msg: "method",
method: "auth.login",
});
expect(result.version).toBe("TrueNAS-SCALE-24.10");
});
test("rejects when authentication is not successful", async () => {
ws.setResponder((method) => (method.startsWith("auth.") ? false : happyResponder(method)));
const integration = createIntegration(credentials);
await expect(integration.getSystemInfoAsync()).rejects.toThrow();
});
test("surfaces a self-signed certificate as a certificate request error without falling back", async () => {
ws.failures.push({ path: "/api/current", code: "DEPTH_ZERO_SELF_SIGNED_CERT" });
ws.failures.push({ path: "/websocket", code: "DEPTH_ZERO_SELF_SIGNED_CERT" });
const integration = createIntegration(credentials);
const error = await integration.getSystemInfoAsync().catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(IntegrationRequestError);
expect((error as IntegrationRequestError).cause).toMatchObject({ type: "certificate", reason: "untrusted" });
// A certificate failure is endpoint-independent, so the legacy API must not be tried.
expect(ws.constructedUrls).not.toContain("wss://truenas.local/websocket");
});
});
@@ -0,0 +1,50 @@
export type TrueNasApiKind = "jsonRpc" | "legacy";
export type TrueNasResponse = { result: unknown } | { error: unknown };
export interface TrueNasApi {
kind: TrueNasApiKind;
/** Websocket path appended to the integration URL. */
path: `/${string}`;
/** The legacy DDP API requires a "connect" handshake before authentication, the JSON-RPC API does not. */
requiresSessionHandshake: boolean;
/** Builds the request envelope for a method call. */
buildRequest: (id: string, method: string, params: unknown[]) => Record<string, unknown>;
/**
* Returns the response payload when `message` is the reply to `id`,
* otherwise `undefined` so the caller keeps waiting for a later message.
*/
matchResponse: (message: Record<string, unknown>, id: string) => TrueNasResponse | undefined;
}
/**
* JSON-RPC 2.0 API introduced in TrueNAS SCALE 24.10 (Electric Eel) and the
* legacy DDP-style websocket API used before it. Both expose the same
* middleware methods, so only the transport envelope differs.
* @see https://www.truenas.com/docs/api/scale_rest_api.html
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
export const trueNasApis = {
jsonRpc: {
kind: "jsonRpc",
path: "/api/current",
requiresSessionHandshake: false,
buildRequest: (id: string, method: string, params: unknown[]) => ({ jsonrpc: "2.0", id, method, params }),
matchResponse: (message: Record<string, unknown>, id: string) => {
if (message.id !== id || message.jsonrpc !== "2.0") return undefined;
if (message.error != null) return { error: message.error };
return { result: message.result };
},
},
legacy: {
kind: "legacy",
path: "/websocket",
requiresSessionHandshake: true,
buildRequest: (id: string, method: string, params: unknown[]) => ({ id, msg: "method", method, params }),
matchResponse: (message: Record<string, unknown>, id: string) => {
if (message.id !== id || message.msg !== "result") return undefined;
if (message.error != null) return { error: message.error };
return { result: message.result };
},
},
} satisfies Record<TrueNasApiKind, TrueNasApi>;
@@ -0,0 +1,315 @@
import type { RawData } from "ws";
import { WebSocket } from "ws";
import z from "zod";
import { createId } from "@homarr/common";
import { matchErrorCode, RequestError, ResponseError } from "@homarr/common/server";
import {
getAllTrustedCertificatesAsync,
getTrustedCertificateHostnamesAsync,
} from "@homarr/core/infrastructure/certificates";
import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
import type { TrueNasApi } from "./truenas-api";
import { trueNasApis } from "./truenas-api";
const logger = createLogger({ module: "trueNasClient" });
const REQUEST_TIMEOUT_MS = 5000;
type CertificateOptions = IntegrationTestingInput["options"];
export type TrueNasCredentials = { apiKey: string } | { username: string; password: string };
/**
* Handles talking to a TrueNAS server: opening a websocket with Homarr's
* trusted certificates, negotiating the API variant, authenticating, and
* sending method requests. Connections are reused across the per-request
* integration instances and reconnected transparently when the server drops
* an idle socket.
*/
export class TrueNasClient {
// Keyed by integration id. The value is the in-flight connection promise so concurrent
// callers reuse a single socket instead of racing to open duplicates.
private static readonly connectionMap = new Map<string, Promise<TrueNasSocket>>();
private static readonly apiMap = new Map<string, TrueNasApi>();
constructor(
private readonly id: string,
private readonly webSocketUrl: (path: `/${string}`) => URL,
private readonly credentials: TrueNasCredentials,
) {}
/** Sends an authenticated request, reusing the cached connection while it is open. */
public async requestAsync(method: string, params: unknown[] = []): Promise<unknown> {
const socket = await this.getSocketAsync();
return socket.requestAsync(method, params);
}
/**
* Opens a connection with the provided certificate options, authenticates,
* and closes it. Used by the connection test, which may run with updated
* credentials, so any cached connection is dropped and the API re-detected.
*/
public async testAsync(certificate: CertificateOptions): Promise<void> {
this.evictConnection();
TrueNasClient.apiMap.delete(this.id);
const socket = await this.openSocketAsync(certificate);
try {
await this.authenticateAsync(socket);
} finally {
socket.close();
}
}
private getSocketAsync(): Promise<TrueNasSocket> {
let connection = TrueNasClient.connectionMap.get(this.id);
if (!connection) {
connection = this.connectAndAuthenticateAsync();
TrueNasClient.connectionMap.set(this.id, connection);
const evict = () => {
if (TrueNasClient.connectionMap.get(this.id) === connection) {
TrueNasClient.connectionMap.delete(this.id);
}
};
// Reconnect on the next request once this attempt fails or the server drops the socket.
connection.then((socket) => socket.onClose(evict), evict);
}
return connection;
}
private async connectAndAuthenticateAsync(): Promise<TrueNasSocket> {
const socket = await this.openSocketAsync(await resolveCertificateOptionsAsync());
try {
await this.authenticateAsync(socket);
} catch (error) {
// Never keep an unauthenticated socket, it would be reused without re-authenticating.
socket.close();
throw error;
}
return socket;
}
private evictConnection() {
const connection = TrueNasClient.connectionMap.get(this.id);
if (!connection) return;
TrueNasClient.connectionMap.delete(this.id);
connection.then((socket) => socket.close()).catch(() => undefined);
}
/**
* Connects using the JSON-RPC API (TrueNAS 24.10+) when available and falls
* back to the legacy websocket API otherwise. A missing JSON-RPC endpoint
* surfaces as a plain protocol error; certificate, connection, DNS and
* timeout errors are endpoint-independent and are never retried.
*/
private async openSocketAsync(certificate: CertificateOptions): Promise<TrueNasSocket> {
const cachedApi = TrueNasClient.apiMap.get(this.id);
if (cachedApi) {
return openTrueNasSocketAsync(this.webSocketUrl(cachedApi.path), cachedApi, certificate);
}
try {
const socket = await openTrueNasSocketAsync(
this.webSocketUrl(trueNasApis.jsonRpc.path),
trueNasApis.jsonRpc,
certificate,
);
TrueNasClient.apiMap.set(this.id, trueNasApis.jsonRpc);
return socket;
} catch (error) {
if (error instanceof RequestError) throw error;
logger.debug("JSON-RPC API unavailable, falling back to the legacy websocket API", {
error: error instanceof Error ? error.message : undefined,
});
const socket = await openTrueNasSocketAsync(
this.webSocketUrl(trueNasApis.legacy.path),
trueNasApis.legacy,
certificate,
);
TrueNasClient.apiMap.set(this.id, trueNasApis.legacy);
return socket;
}
}
/**
* Authenticates with an API key when one is configured, otherwise with the
* username and password. Both methods work on either API.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
private async authenticateAsync(socket: TrueNasSocket): Promise<void> {
const response =
"apiKey" in this.credentials
? await socket.requestAsync("auth.login_with_api_key", [this.credentials.apiKey])
: await socket.requestAsync("auth.login", [this.credentials.username, this.credentials.password]);
const success = await z.boolean().parseAsync(response);
if (!success) throw new ResponseError({ status: 401 });
}
}
/**
* Wraps a single websocket connection and speaks whichever TrueNAS API
* ({@link trueNasApis}) was negotiated when it was opened.
*/
class TrueNasSocket {
constructor(
private readonly webSocket: WebSocket,
private readonly api: TrueNasApi,
) {}
public close() {
this.webSocket.close();
}
public onClose(listener: () => void) {
this.webSocket.once("close", listener);
}
/**
* The legacy DDP API requires a "connect" handshake to obtain a session
* before any method can be called.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
public registerSessionAsync(): Promise<void> {
return new Promise((resolve, reject) => {
const handler = (raw: RawData) => {
const message = parseMessage(raw);
if (message?.msg === "connected") {
this.webSocket.off("message", handler);
resolve();
} else if (message?.msg === "failed") {
this.webSocket.off("message", handler);
reject(new Error("Unable to establish TrueNAS session"));
}
};
this.webSocket.on("message", handler);
this.webSocket.send(JSON.stringify({ msg: "connect", version: "1", support: ["1"] }));
});
}
/**
* Sends a method request and resolves with its result. Rejects after
* {@link REQUEST_TIMEOUT_MS} when no matching response was received.
*/
public requestAsync(method: string, params: unknown[] = []): Promise<unknown> {
return new Promise((resolve, reject) => {
const id = createId();
const cleanup = () => {
clearTimeout(timeoutId);
this.webSocket.off("message", handler);
this.webSocket.off("close", onClose);
this.webSocket.off("error", onError);
};
const handler = (raw: RawData) => {
const message = parseMessage(raw);
if (!message) return;
const response = this.api.matchResponse(message, id);
if (!response) return;
cleanup();
if ("error" in response) {
reject(new Error(`TrueNAS method "${method}" failed: ${JSON.stringify(response.error)}`));
return;
}
logger.debug("Received method response", { id, method, api: this.api.kind });
resolve(response.result);
};
// A dropped connection would otherwise leave the request hanging until the timeout below.
const onClose = () => {
cleanup();
reject(
new RequestError(
{ type: "connection", reason: "reset", code: "ECONNRESET" },
{ cause: new Error("Connection closed before a response was received") },
),
);
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const timeoutId = setTimeout(() => {
cleanup();
reject(
new RequestError(
{
type: "timeout",
reason: "aborted",
code: "ECONNABORTED",
},
{ cause: new Error("Canceled request after 5 seconds") },
),
);
}, REQUEST_TIMEOUT_MS);
this.webSocket.on("message", handler);
this.webSocket.once("close", onClose);
this.webSocket.once("error", onError);
logger.debug("Sending method request", { id, method, api: this.api.kind });
this.webSocket.send(JSON.stringify(this.api.buildRequest(id, method, params)));
});
}
}
/**
* Opens a websocket using Homarr's trusted certificates and performs the
* session handshake when the API requires it. TLS, connection, DNS and timeout
* failures are translated into {@link RequestError}s so the connection test can
* offer to trust a self-signed certificate, exactly like the HTTP integrations.
*/
const openTrueNasSocketAsync = async (
url: URL,
api: TrueNasApi,
certificate: CertificateOptions,
): Promise<TrueNasSocket> => {
logger.debug("Connecting to TrueNAS websocket", { url: url.toString(), api: api.kind });
const webSocket = await new Promise<WebSocket>((resolve, reject) => {
const socket = new WebSocket(url, {
ca: certificate.ca,
// The @types/ws contract types this as returning a boolean, but ws forwards it to
// tls.connect which uses Node's `Error | undefined` contract that our helper follows.
checkServerIdentity: certificate.checkServerIdentity as never,
});
socket.once("open", () => resolve(socket));
socket.once("error", (error: Error & { code?: string }) => {
const matched = error.code ? matchErrorCode(error.code) : undefined;
reject(matched ? new RequestError(matched, { cause: error }) : error);
});
});
const socket = new TrueNasSocket(webSocket, api);
if (api.requiresSessionHandshake) await socket.registerSessionAsync();
return socket;
};
const resolveCertificateOptionsAsync = async (): Promise<CertificateOptions> => ({
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
});
const parseMessage = (raw: RawData): Record<string, unknown> | undefined => {
try {
const parsed: unknown = JSON.parse(raw.toString());
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined;
} catch {
return undefined;
}
};
@@ -1,8 +1,6 @@
import dayjs from "dayjs";
import z from "zod";
import { createId } from "@homarr/common";
import { RequestError, ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
@@ -10,252 +8,21 @@ import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
import type { TrueNasCredentials } from "./truenas-client";
import { TrueNasClient } from "./truenas-client";
const logger = createLogger({ module: "trueNasIntegration" });
const NETWORK_MULTIPLIER = 100;
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
private static webSocketMap = new Map<string, WebSocket>();
private wsUrl() {
const url = super.url("/websocket");
url.protocol = url.protocol.replace("http", "ws");
return url;
}
private get webSocket() {
return TrueNasIntegration.webSocketMap.get(this.integration.id) ?? null;
}
protected async testingAsync(_input: IntegrationTestingInput): Promise<TestingResult> {
const webSocket = await this.connectWebSocketAsync();
await this.registerSessionAsync(webSocket);
await this.authenticateWebSocketAsync(webSocket);
// Remove current socket connection so we can authenticate with updated credentials
TrueNasIntegration.webSocketMap.delete(this.integration.id);
private client?: TrueNasClient;
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
await this.getClient().testAsync(input.options);
return { success: true };
}
/**
* TrueNAS API uses WebSocket. This function connects to the socket
* and resolves the promise if the connection was successful.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html
*/
private async connectWebSocketAsync(): Promise<WebSocket> {
logger.debug("Connecting to websocket server", {
url: this.wsUrl(),
});
const webSocket = new WebSocket(this.wsUrl());
return new Promise((resolve, reject) => {
webSocket.onopen = () => {
logger.debug("Connected to websocket server", {
url: this.wsUrl(),
});
resolve(webSocket);
};
webSocket.onerror = () => {
reject(new Error("Failed to connect"));
};
});
}
/**
* Before authentication, a session must be obtained from the server using the "connect" event.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
private async registerSessionAsync(webSocket: WebSocket): Promise<void> {
return new Promise((resolve, reject) => {
const subscribe = (event: MessageEvent<string>) => {
const data = JSON.parse(event.data) as { msg: string };
if (data.msg === "connected") {
webSocket.removeEventListener("message", subscribe);
resolve();
} else if (data.msg === "failed") {
webSocket.removeEventListener("message", subscribe);
reject(new Error("Unable to establish connection"));
}
};
webSocket.addEventListener("message", subscribe);
webSocket.send(
JSON.stringify({
msg: "connect",
version: "1", // this must be number, not string
support: ["1"], // this must be number, not string
}),
);
});
}
/**
* After a session was obtained, the session can be authenticated.
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
*/
private async authenticateWebSocketAsync(webSocket?: WebSocket): Promise<void> {
logger.debug("Authenticating with username and password", {
url: this.wsUrl(),
});
const response = await this.requestAsync(
"auth.login",
[this.getSecretValue("username"), this.getSecretValue("password")],
webSocket,
);
const result = await z.boolean().parseAsync(response);
if (!result) throw new ResponseError({ status: 401 });
logger.debug("Authenticated successfully with username and password", {
url: this.wsUrl(),
});
}
private async getPoolsAsync() {
logger.debug("Retrieving pools", {
url: this.wsUrl(),
});
const response = await this.requestAsync("pool.query", [
[],
{
extra: {
is_upgraded: true,
},
},
]);
const result = await poolSchema.parseAsync(response);
// Offline / exported pools have null values for allocated, size and free, so we filter them out
// See https://github.com/homarr-labs/homarr/issues/5194
const activePools = result
.map((pool) => {
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
return {
...pool,
allocated: pool.allocated,
size: pool.size,
free: pool.free,
};
}
return null;
})
.filter((pool) => pool !== null);
logger.debug("Retrieved pools", {
url: this.wsUrl(),
totalCount: result.length,
activeCount: activePools.length,
});
return activePools;
}
/**
* Retrieves data using the reporting method
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
*/
private async getReportingAsync(): Promise<ReportingItem[]> {
logger.debug("Retrieving reporting data", {
url: this.wsUrl(),
});
const response = await this.requestAsync("reporting.get_data", [
[
{
name: "cpu",
},
{
name: "memory",
},
{
name: "cputemp",
},
],
{
aggregate: true,
start: dayjs().add(-5, "minutes").unix(),
end: dayjs().unix(),
},
]);
const result = await z.array(reportingItemSchema).parseAsync(response);
logger.debug("Retrieved reporting data", {
url: this.wsUrl(),
count: result.length,
});
return result;
}
/**
* Retrieves a list of all available network interfaces
* @see https://www.truenas.com/docs/core/13.0/api/core_websocket_api.html#interface
*/
private async getNetworkInterfacesAsync(): Promise<z.infer<typeof networkInterfaceSchema>> {
logger.debug("Retrieving available network-interfaces", {
url: this.wsUrl(),
});
const response = await this.requestAsync("interface.query", [
[], // no filters
{},
]);
const result = await networkInterfaceSchema.parseAsync(response);
logger.debug("Retrieved available network-interfaces", {
url: this.wsUrl(),
count: result.length,
});
return result;
}
/**
* Retrieves reporting network data of the last 5 minutes
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
*/
private async getReportingNetdataAsync(): Promise<z.infer<typeof reportingNetDataSchema>> {
const networkInterfaces = await this.getNetworkInterfacesAsync();
logger.debug("Retrieving reporting network data", {
url: this.wsUrl(),
});
const response = await this.requestAsync("reporting.netdata_get_data", [
networkInterfaces.map((networkInterface) => ({
name: "interface",
identifier: networkInterface.id,
})),
{
start: dayjs().add(-5, "minutes").unix(),
end: dayjs().unix(),
},
]);
const result = await reportingNetDataSchema.parseAsync(response);
logger.debug("Retrieved reporting-network-data", {
url: this.wsUrl(),
count: result.length,
});
return result;
}
/**
* Retrieves information about the system
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#system
*/
private async getSystemInformationAsync(): Promise<z.infer<typeof systemInfoSchema>> {
logger.debug("Retrieving system-information", {
url: this.wsUrl(),
});
const response = await this.requestAsync("system.info");
const result = await systemInfoSchema.parseAsync(response);
logger.debug("Retrieved system-information", {
url: this.wsUrl(),
});
return result;
}
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
const systemInformation = await this.getSystemInformationAsync();
const reporting = await this.getReportingAsync();
@@ -272,7 +39,7 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
return {
cpuUtilization: cpuData.reduce((acc, item) => acc + (item > 100 ? 0 : item), 0) / cpuData.length,
cpuTemp: Math.max(...cpuTempData.filter((_item, i) => i > 0)),
cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)),
memAvailableInBytes: systemInformation.physmem,
memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes
fileSystem: datasets.map((dataset) => ({
@@ -301,87 +68,164 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
};
}
/**
* Send a request through websocket and return response
* Times out after 5 seconds when no response was received.
* @param method json-rpc method to call
* @param params array of parameters
* @param webSocketOverride override of webSocket, helpful for not storing the connection
* @returns result of json-rpc call
*/
private async requestAsync(method: string, params: unknown[] = [], webSocketOverride?: WebSocket) {
let webSocket = webSocketOverride ?? this.webSocket;
if (webSocket?.readyState !== WebSocket.OPEN) {
logger.debug("Connecting to websocket", {
url: this.wsUrl(),
});
// We can only land here with static webSocket
webSocket = await this.connectWebSocketAsync();
await this.registerSessionAsync(webSocket);
TrueNasIntegration.webSocketMap.set(this.integration.id, webSocket);
await this.authenticateWebSocketAsync();
}
return await new Promise((resolve, reject) => {
const id = createId();
const handler = (event: MessageEvent<string>) => {
const data = JSON.parse(event.data) as Record<string, unknown>;
if (data.msg !== "result") return;
if (data.id !== id) return;
clearTimeout(timeoutId);
webSocket.removeEventListener("message", handler);
logger.debug("Received method response", {
id,
method,
url: this.wsUrl(),
});
resolve(data.result);
};
const timeoutId = setTimeout(() => {
webSocket.removeEventListener("message", handler);
reject(
new RequestError(
{
type: "timeout",
reason: "aborted",
code: "ECONNABORTED",
},
{ cause: new Error("Canceled request after 5 seconds") },
),
);
}, 5000);
webSocket.addEventListener("message", handler);
logger.debug("Sending method request", {
id,
method,
url: this.wsUrl(),
});
webSocket.send(
JSON.stringify({
id,
msg: "method",
method,
params,
}),
);
});
private getClient(): TrueNasClient {
return (this.client ??= this.createClient());
}
private extractNetworkTrafficData = (data: z.infer<typeof reportingNetDataSchema>, index = 1 | 2) => {
private createClient(): TrueNasClient {
const credentials: TrueNasCredentials = this.hasSecretValue("apiKey")
? { apiKey: this.getSecretValue("apiKey") }
: { username: this.getSecretValue("username"), password: this.getSecretValue("password") };
return new TrueNasClient(this.integration.id, (path) => this.webSocketUrl(path), credentials);
}
private async requestAsync(method: string, params: unknown[] = []): Promise<unknown> {
return this.getClient().requestAsync(method, params);
}
private async getPoolsAsync() {
logger.debug("Retrieving pools", { url: this.integration.url });
const response = await this.requestAsync("pool.query", [
[],
{
extra: {
is_upgraded: true,
},
},
]);
const result = await poolSchema.parseAsync(response);
// Offline / exported pools have null values for allocated, size and free, so we filter them out
// See https://github.com/homarr-labs/homarr/issues/5194
const activePools = result
.map((pool) => {
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
return {
...pool,
allocated: pool.allocated,
size: pool.size,
free: pool.free,
};
}
return null;
})
.filter((pool) => pool !== null);
logger.debug("Retrieved pools", {
url: this.integration.url,
totalCount: result.length,
activeCount: activePools.length,
});
return activePools;
}
/**
* Retrieves data using the reporting method
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
*/
private async getReportingAsync(): Promise<ReportingItem[]> {
logger.debug("Retrieving reporting data", { url: this.integration.url });
const response = await this.requestAsync("reporting.get_data", [
[
{
name: "cpu",
},
{
name: "memory",
},
{
name: "cputemp",
},
],
{
aggregate: true,
start: dayjs().add(-5, "minutes").unix(),
end: dayjs().unix(),
},
]);
const result = await z.array(reportingItemSchema).parseAsync(response);
logger.debug("Retrieved reporting data", {
url: this.integration.url,
count: result.length,
});
return result;
}
/**
* Retrieves a list of all available network interfaces
* @see https://www.truenas.com/docs/core/13.0/api/core_websocket_api.html#interface
*/
private async getNetworkInterfacesAsync(): Promise<z.infer<typeof networkInterfaceSchema>> {
logger.debug("Retrieving available network-interfaces", { url: this.integration.url });
const response = await this.requestAsync("interface.query", [
[], // no filters
{},
]);
const result = await networkInterfaceSchema.parseAsync(response);
logger.debug("Retrieved available network-interfaces", {
url: this.integration.url,
count: result.length,
});
return result;
}
/**
* Retrieves reporting network data of the last 5 minutes
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
*/
private async getReportingNetdataAsync(): Promise<z.infer<typeof reportingNetDataSchema>> {
const networkInterfaces = await this.getNetworkInterfacesAsync();
logger.debug("Retrieving reporting network data", { url: this.integration.url });
const response = await this.requestAsync("reporting.netdata_get_data", [
networkInterfaces.map((networkInterface) => ({
name: "interface",
identifier: networkInterface.id,
})),
{
start: dayjs().add(-5, "minutes").unix(),
end: dayjs().unix(),
},
]);
const result = await reportingNetDataSchema.parseAsync(response);
logger.debug("Retrieved reporting-network-data", {
url: this.integration.url,
count: result.length,
});
return result;
}
/**
* Retrieves information about the system
* @see https://www.truenas.com/docs/api/scale_websocket_api.html#system
*/
private async getSystemInformationAsync(): Promise<z.infer<typeof systemInfoSchema>> {
logger.debug("Retrieving system-information", { url: this.integration.url });
const response = await this.requestAsync("system.info");
const result = await systemInfoSchema.parseAsync(response);
logger.debug("Retrieved system-information", { url: this.integration.url });
return result;
}
private extractNetworkTrafficData = (data: z.infer<typeof reportingNetDataSchema>, index: 1 | 2) => {
return data.reduce((acc, current) => acc + (current.data.at(-1)?.at(index) ?? 0), 0);
};
private extractLatestReportingData(data: ReportingItem[], key: ReportingItem["identifier"]) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const dataObject = data.find((item) => item.identifier === key)!;
// TODO: check why the below sorting is done, because right now it compares number[] with number[]?
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return dataObject.data.sort((item1, item2) => (item1 > item2 ? -1 : 1))[0]!;
private extractLatestReportingData(data: ReportingItem[], identifier: ReportingItem["identifier"]) {
const item = data.find((reportingItem) => reportingItem.identifier === identifier);
// Reporting rows are [timestamp, ...values] in ascending time order, so the last row is the most recent reading.
const latest = item?.data.at(-1);
if (!latest) throw new Error(`TrueNAS returned no "${identifier}" reporting data`);
return latest;
}
}
+1
View File
@@ -53,6 +53,7 @@ import * as weather from "./weather";
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
export type { WidgetComponentProps };
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
export const widgetImports = {
clock,
+1029 -1019
View File
File diff suppressed because it is too large. Load diff
+42 -42
View File
@@ -14,25 +14,25 @@ catalog:
"@dnd-kit/utilities": ^3.2.2
"@drizzle-team/brocli": ^0.12.0
"@extractus/feed-extractor": 7.2.1
'@gfazioli/mantine-onboarding-tour': ^4.0.1
'@gfazioli/mantine-onboarding-tour': ^4.0.4
"@gitbeaker/rest": ^43.8.0
"@homarr/gridstack": ^1.12.0
"@homarr/node-unifi": ^2.6.0
"@immich/sdk": ^2.7.5
"@jellyfin/sdk": ^0.13.0
"@kubernetes/client-node": ^1.4.0
"@mantine/charts": ^9.2.2
"@mantine/colors-generator": ^9.2.2
"@mantine/core": ^9.2.2
"@mantine/dates": ^9.2.2
"@mantine/dropzone": ^9.2.2
"@mantine/form": ^9.2.2
"@mantine/hooks": ^9.2.2
"@mantine/modals": ^9.2.2
"@mantine/notifications": ^9.2.2
"@mantine/spotlight": ^9.2.2
"@mantine/tiptap": ^9.2.2
"@next/eslint-plugin-next": 16.2.6
"@mantine/charts": ^9.3.0
"@mantine/colors-generator": ^9.3.0
"@mantine/core": ^9.3.0
"@mantine/dates": ^9.3.0
"@mantine/dropzone": ^9.3.0
"@mantine/form": ^9.3.0
"@mantine/hooks": ^9.3.0
"@mantine/modals": ^9.3.0
"@mantine/notifications": ^9.3.0
"@mantine/spotlight": ^9.3.0
"@mantine/tiptap": ^9.3.0
"@next/eslint-plugin-next": 16.2.7
"@octokit/auth-app": ^8.2.0
"@paralleldrive/cuid2": ^3.3.0
"@playwright/test": ^1.60.0
@@ -44,28 +44,28 @@ catalog:
"@semantic-release/release-notes-generator": ^14.1.1
"@t3-oss/env-nextjs": ^0.13.11
"@tabler/icons-react": ^3.44.0
"@tanstack/react-query": ^5.100.14
"@tanstack/react-query-devtools": ^5.100.14
"@tanstack/react-query-next-experimental": ^5.100.14
"@tanstack/react-query": ^5.101.0
"@tanstack/react-query-devtools": ^5.101.0
"@tanstack/react-query-next-experimental": ^5.101.0
"@testcontainers/mysql": ^12.0.1
"@testcontainers/postgresql": ^12.0.1
"@testcontainers/redis": ^12.0.1
"@tiptap/extension-color": 3.24.0
"@tiptap/extension-highlight": 3.24.0
"@tiptap/extension-image": 3.24.0
"@tiptap/extension-link": ^3.24.0
"@tiptap/extension-placeholder": ^3.24.0
"@tiptap/extension-table": 3.24.0
"@tiptap/extension-table-cell": 3.24.0
"@tiptap/extension-table-header": 3.24.0
"@tiptap/extension-table-row": 3.24.0
"@tiptap/extension-task-item": 3.24.0
"@tiptap/extension-task-list": 3.24.0
"@tiptap/extension-text-align": 3.24.0
"@tiptap/extension-text-style": 3.24.0
"@tiptap/extension-underline": 3.24.0
"@tiptap/react": ^3.24.0
"@tiptap/starter-kit": ^3.24.0
"@tiptap/extension-color": 3.25.0
"@tiptap/extension-highlight": 3.25.0
"@tiptap/extension-image": 3.25.0
"@tiptap/extension-link": ^3.25.0
"@tiptap/extension-placeholder": ^3.25.0
"@tiptap/extension-table": 3.25.0
"@tiptap/extension-table-cell": 3.25.0
"@tiptap/extension-table-header": 3.25.0
"@tiptap/extension-table-row": 3.25.0
"@tiptap/extension-task-item": 3.25.0
"@tiptap/extension-task-list": 3.25.0
"@tiptap/extension-text-align": 3.25.0
"@tiptap/extension-text-style": 3.25.0
"@tiptap/extension-underline": 3.25.0
"@tiptap/react": ^3.25.0
"@tiptap/starter-kit": ^3.25.0
"@trpc/client": ^11.17.0
"@trpc/next": ^11.17.0
"@trpc/react-query": ^11.17.0
@@ -84,7 +84,7 @@ catalog:
"@types/node-unifi": ^2.5.1
"@types/pg": ^8.20.0
"@types/prismjs": ^1.26.6
"@types/react": 19.2.15
"@types/react": 19.2.16
"@types/react-dom": 19.2.3
"@types/swagger-ui-react": ^5.18.0
"@types/video.js": ^7.3.58
@@ -92,8 +92,8 @@ catalog:
"@types/xml2js": ^0.4.14
"@umami/node": ^0.4.0
"@vitejs/plugin-react": ^5.2.0
"@vitest/coverage-v8": ^4.1.7
"@vitest/ui": ^4.1.7
"@vitest/coverage-v8": ^4.1.8
"@vitest/ui": ^4.1.8
"@xterm/addon-canvas": ^0.7.0
"@xterm/addon-fit": 0.11.0
"@xterm/xterm": ^6.0.0
@@ -126,7 +126,7 @@ catalog:
glob: ^13.0.6
ical.js: ^2.2.1
ioredis: 5.11.0
isomorphic-dompurify: ^3.15.0
isomorphic-dompurify: ^3.16.0
jotai: ^2.20.0
jsdom: ^29.1.1
json5: ^2.2.3
@@ -135,19 +135,19 @@ catalog:
mantine-react-table: npm:mantine-react-table-open@9.0.3
maria2: ^0.4.1
mysql2: 3.22.4
next: 16.2.6
next: 16.2.7
next-auth: 5.0.0-beta.31
next-intl: 4.13.0
node-loader: ^2.1.0
octokit: ^5.0.5
oxfmt: ^0.52.0
oxfmt: ^0.53.0
oxlint: ^1.67.0
pg: ^8.21.0
postcss-preset-mantine: ^1.18.0
prismjs: ^1.30.0
proxmox-api: 1.1.1
react: 19.2.6
react-dom: 19.2.6
react: 19.2.7
react-dom: 19.2.7
react-error-boundary: ^6.1.2
react-markdown: ^10.1.0
react-quill-new: ^3.8.3
@@ -155,7 +155,7 @@ catalog:
recharts: ^3.8.1
sass: ^1.100.0
semantic-release: ^25.0.3
semver-parser: ^4.2.0
semver-parser: ^4.2.1
superjson: 2.2.6
svgson: ^5.3.1
swagger-ui-react: ^5.32.6
@@ -170,7 +170,7 @@ catalog:
use-deep-compare-effect: ^1.8.1
video.js: ^8.23.7
vite-tsconfig-paths: ^6.1.1
vitest: ^4.1.7
vitest: ^4.1.8
winston: 3.19.0
ws: ^8.21.0
xml2js: ^0.6.2