Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99e013c1fb | ||
|
|
b3cdab579b | ||
|
|
44a0c6ce54 | ||
|
|
7f05e0325d | ||
|
|
f71b145403 | ||
|
|
607fd498f4 | ||
|
|
3f2fb54cdf | ||
|
|
69412f2d10 | ||
|
|
786ad347a0 | ||
|
|
f6d5f97933 | ||
|
|
a447a9f615 | ||
|
|
93ce4e4a1b | ||
|
|
4cf41f2c4c | ||
|
|
ab3c8b4d1f | ||
|
|
78ddc81b15 | ||
|
|
ad98f2d262 | ||
|
|
8a1ead3674 | ||
|
|
9c05f44916 |
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
@@ -102,6 +102,10 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.grid-stack > .grid-stack-item[data-type="item"] {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-animate {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
+2
-2
@@ -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",
|
||||
@@ -89,7 +89,7 @@
|
||||
"prismjs@<1.30.0": ">=1.30.0",
|
||||
"proxmox-api>undici": "7.24.6",
|
||||
"react-is": "^19.2.7",
|
||||
"rollup@>=4.0.0 <4.22.4": ">=4.61.0",
|
||||
"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",
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { publicProcedure } from "../trpc";
|
||||
|
||||
export type IntegrationAction = "query" | "interact" | "use";
|
||||
export type IntegrationAction = "query" | "interact";
|
||||
|
||||
/**
|
||||
* Creates a middleware that provides the integration in the context that is of the specified kinds
|
||||
@@ -167,18 +167,6 @@ const throwIfActionIsNotAllowedAsync = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "use") {
|
||||
const haveAllUseAccess = integrations
|
||||
.map((integration) => constructIntegrationPermissions(integration, session))
|
||||
.every(({ hasUseAccess }) => hasUseAccess);
|
||||
if (haveAllUseAccess) return;
|
||||
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User does not have permission to use at least one of the specified integrations",
|
||||
});
|
||||
}
|
||||
|
||||
const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
|
||||
|
||||
if (hasQueryAccess) return;
|
||||
|
||||
@@ -32,5 +32,4 @@ export const widgetRouter = createTRPCRouter({
|
||||
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
|
||||
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
|
||||
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
|
||||
systemUsage: lazy(() => import("./system-usage").then((mod) => mod.systemUsageRouter)),
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import z from "zod";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { System } from "@homarr/integrations/types";
|
||||
import { systemUsageRequestHandler } from "@homarr/request-handler/system-usage";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const supportedIntegrations = getIntegrationKindsByCategory("systemUsage");
|
||||
|
||||
export const systemUsageRouter = createTRPCRouter({
|
||||
listSystems: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("use", ...supportedIntegrations))
|
||||
.query(async ({ ctx: { integration } }) => {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
|
||||
return await instance.getSystemsAsync();
|
||||
}),
|
||||
get: publicProcedure
|
||||
.input(z.object({ systemId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("query", ...supportedIntegrations))
|
||||
.query(async ({ ctx: { integration }, input }) => {
|
||||
const innerHandler = systemUsageRequestHandler.handler(integration, { systemId: input.systemId });
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
}),
|
||||
subscribe: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", ...supportedIntegrations))
|
||||
.input(z.object({ systemId: z.string() }))
|
||||
.subscription(({ input, ctx }) => {
|
||||
return observable<{
|
||||
system: System;
|
||||
}>((emit) => {
|
||||
const innerHandler = systemUsageRequestHandler.handler(ctx.integration, {
|
||||
systemId: input.systemId,
|
||||
});
|
||||
const unsubscribe = innerHandler.subscribe((system) => {
|
||||
emit.next({ system });
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -18,7 +18,6 @@ import { mediaServerJob } from "./jobs/integrations/media-server";
|
||||
import { mediaTranscodingJob } from "./jobs/integrations/media-transcoding";
|
||||
import { networkControllerJob } from "./jobs/integrations/network-controller";
|
||||
import { refreshNotificationsJob } from "./jobs/integrations/notifications";
|
||||
import { systemUsageJob } from "./jobs/integrations/system-usage";
|
||||
import { timetableJob } from "./jobs/integrations/timetable";
|
||||
import { tracearrJob } from "./jobs/integrations/tracearr";
|
||||
import { umamiActiveVisitorsJob } from "./jobs/integrations/umami";
|
||||
@@ -56,7 +55,6 @@ const getJobGroup = () => {
|
||||
refreshNotifications: refreshNotificationsJob,
|
||||
weather: weatherJob,
|
||||
timetable: timetableJob,
|
||||
systemUsage: systemUsageJob,
|
||||
tracearr: tracearrJob,
|
||||
umamiActiveVisitors: umamiActiveVisitorsJob,
|
||||
});
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
|
||||
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||
import { systemUsageRequestHandler } from "@homarr/request-handler/system-usage";
|
||||
|
||||
import { createCronJob } from "../../lib";
|
||||
|
||||
export const systemUsageJob = createCronJob("systemUsage", EVERY_MINUTE).withCallback(
|
||||
createRequestIntegrationJobHandler(systemUsageRequestHandler.handler, {
|
||||
widgetKinds: ["systemUsage"],
|
||||
getInput: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
systemUsage: (options) =>
|
||||
options.systemId
|
||||
? {
|
||||
systemId: options.systemId.value,
|
||||
}
|
||||
: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -156,7 +156,6 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/adguard-home"
|
||||
| "/docs/integrations/anchor"
|
||||
| "/docs/integrations/aria2"
|
||||
| "/docs/integrations/beszel"
|
||||
| "/docs/integrations/codeberg"
|
||||
| "/docs/integrations/coolify"
|
||||
| "/docs/integrations/dash-dot"
|
||||
|
||||
@@ -364,19 +364,12 @@ 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"),
|
||||
defaultPort: 80,
|
||||
},
|
||||
beszel: {
|
||||
name: "Beszel",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/beszel.svg",
|
||||
category: ["systemUsage"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
|
||||
},
|
||||
unraid: {
|
||||
name: "Unraid",
|
||||
secretKinds: [["apiKey"]],
|
||||
@@ -458,7 +451,6 @@ export const integrationDefs = {
|
||||
"networkController",
|
||||
"notifications",
|
||||
"smartHomeServer",
|
||||
"systemUsage",
|
||||
"ups",
|
||||
],
|
||||
documentationUrl: null,
|
||||
@@ -547,7 +539,6 @@ export const integrationCategories = [
|
||||
"mediaMonitoring",
|
||||
"speedtest",
|
||||
"analytics",
|
||||
"systemUsage",
|
||||
"ups",
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ export const widgetKinds = [
|
||||
"firewall",
|
||||
"notifications",
|
||||
"systemResources",
|
||||
"systemUsage",
|
||||
"coolify",
|
||||
"systemDisks",
|
||||
"timetable",
|
||||
|
||||
@@ -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:"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
||||
import { AnchorIntegration } from "../anchor/anchor-integration";
|
||||
import { BeszelIntegration } from "../beszel/beszel-integration";
|
||||
import { CodebergIntegration } from "../codeberg/codeberg-integration";
|
||||
import { CoolifyIntegration } from "../coolify/coolify-integration";
|
||||
import { DashDotIntegration } from "../dashdot/dashdot-integration";
|
||||
@@ -117,7 +116,6 @@ export const integrationCreators = {
|
||||
ntfy: NTFYIntegration,
|
||||
mock: MockIntegration,
|
||||
truenas: TrueNasIntegration,
|
||||
beszel: BeszelIntegration,
|
||||
unraid: UnraidIntegration,
|
||||
coolify: CoolifyIntegration,
|
||||
tracearr: TracearrIntegration,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import type { fetch as undiciFetch } from "undici";
|
||||
import z from "zod";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
|
||||
import { HandleIntegrationErrors } from "../base/errors/decorator";
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { ISystemUsageIntegration } from "../interfaces/system-usage/system-usage-integration";
|
||||
import type { AgentConnectionType, System, SystemLoadStatus } from "../interfaces/system-usage/system-usage-types";
|
||||
|
||||
@HandleIntegrationErrors([])
|
||||
export class BeszelIntegration extends Integration implements ISystemUsageIntegration {
|
||||
protected async testingAsync({ fetchAsync }: IntegrationTestingInput): Promise<TestingResult> {
|
||||
await this.authenticateAsync(fetchAsync);
|
||||
return { success: true as const };
|
||||
}
|
||||
|
||||
public async getSystemsAsync() {
|
||||
const token = await this.authenticateAsync();
|
||||
const response = await this.fetchApi("/api/collections/systems/records?perPage=500", token);
|
||||
const data = z.object({ items: z.array(systemSchema) }).parse(response);
|
||||
return data.items.map((system) => ({
|
||||
id: system.id,
|
||||
name: system.name,
|
||||
}));
|
||||
}
|
||||
|
||||
public async getSystemDetailsAsync(id: string): Promise<System> {
|
||||
const token = await this.authenticateAsync();
|
||||
const response = await this.fetchApi(`/api/collections/systems/records/${id}`, token);
|
||||
const system = systemSchema.parse(response);
|
||||
|
||||
return {
|
||||
id: system.id,
|
||||
name: system.name,
|
||||
status: system.status,
|
||||
agent: {
|
||||
connectionType: system.info.ct !== undefined ? (connectionTypeByCode[system.info.ct] ?? null) : null,
|
||||
version: system.info.v,
|
||||
},
|
||||
usage: {
|
||||
cpuPercentage: system.info.cpu,
|
||||
memoryPercentage: system.info.mp,
|
||||
diskPercentage: system.info.dp,
|
||||
gpuPercentage: system.info.g ?? null,
|
||||
load: {
|
||||
status: this.calculateLoadStatus(system.status, system.info.la, system.info.t),
|
||||
averages: {
|
||||
one: system.info.la[0],
|
||||
five: system.info.la[1],
|
||||
fifteen: system.info.la[2],
|
||||
},
|
||||
},
|
||||
networkBytes: system.info.bb,
|
||||
temperature: system.info.dt ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private calculateLoadStatus(
|
||||
systemStatus: BeszelSystem["status"],
|
||||
loadAverages: [number, number, number],
|
||||
threatCount: number | undefined,
|
||||
): SystemLoadStatus {
|
||||
if (systemStatus !== "up") return "unknown";
|
||||
|
||||
const maxLoad = Math.max(...loadAverages);
|
||||
const loadPerThread = maxLoad / (threatCount ?? 1);
|
||||
const meter = loadPerThread * 100;
|
||||
if (meter >= 90) return "critical";
|
||||
if (meter >= 65) return "warning";
|
||||
return "good";
|
||||
}
|
||||
|
||||
private async fetchApi(
|
||||
path: string,
|
||||
token: string,
|
||||
customFetch: typeof undiciFetch = fetchWithTrustedCertificatesAsync,
|
||||
) {
|
||||
const response = await customFetch(this.url(path).toString(), {
|
||||
headers: { Authorization: token },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
private async authenticateAsync(customFetch: typeof undiciFetch = fetchWithTrustedCertificatesAsync) {
|
||||
const response = await customFetch(this.url("/api/collections/users/auth-with-password").toString(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
identity: this.getSecretValue("username"),
|
||||
password: this.getSecretValue("password"),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
// PocketBase returns 400 for failed auth — remap to 401 for proper authorization error classification
|
||||
const status = response.status === 400 ? 401 : response.status;
|
||||
throw new ResponseError({ status, url: response.url });
|
||||
}
|
||||
const data = authResponseSchema.parse(await response.json());
|
||||
return data.token;
|
||||
}
|
||||
}
|
||||
|
||||
const connectionTypeByCode: Record<number, AgentConnectionType> = {
|
||||
1: "ssh",
|
||||
2: "webSocket",
|
||||
};
|
||||
|
||||
const authResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
const systemStatus = ["up", "down", "paused", "pending"] as const;
|
||||
|
||||
/**
|
||||
* See https://github.com/henrygd/beszel/blob/cca7b360395e3e7e4ef870005efddc3bd75b86c4/internal/entities/system/system.go#L104
|
||||
*/
|
||||
const systemSchema = z.object({
|
||||
id: z.string(),
|
||||
info: z.object({
|
||||
cpu: z.number(),
|
||||
mp: z.number(),
|
||||
dp: z.number(),
|
||||
g: z.number().optional(),
|
||||
la: z.tuple([z.number(), z.number(), z.number()]),
|
||||
bb: z.number(),
|
||||
dt: z.number().optional(),
|
||||
v: z.string(),
|
||||
ct: z.number().optional(),
|
||||
t: z.number().optional(),
|
||||
}),
|
||||
name: z.string(),
|
||||
status: z.enum(systemStatus),
|
||||
});
|
||||
|
||||
type BeszelSystem = z.infer<typeof systemSchema>;
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { System } from "./system-usage-types";
|
||||
|
||||
export interface ISystemUsageIntegration {
|
||||
getSystemsAsync(): Promise<Pick<System, "id" | "name">[]>;
|
||||
getSystemDetailsAsync(id: System["id"]): Promise<System>;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
export type SystemLoadStatus = "good" | "warning" | "critical" | "unknown";
|
||||
export type SystemStatus = "up" | "down" | "paused" | "pending";
|
||||
export type AgentConnectionType = "ssh" | "webSocket";
|
||||
|
||||
export interface System {
|
||||
id: string;
|
||||
name: string;
|
||||
status: SystemStatus;
|
||||
usage: SystemUsage;
|
||||
agent: {
|
||||
connectionType: AgentConnectionType | null;
|
||||
version: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SystemUsage {
|
||||
cpuPercentage: number;
|
||||
memoryPercentage: number;
|
||||
diskPercentage: number;
|
||||
gpuPercentage: number | null;
|
||||
load: {
|
||||
status: SystemLoadStatus;
|
||||
averages: {
|
||||
one: number;
|
||||
five: number;
|
||||
fifteen: number;
|
||||
};
|
||||
};
|
||||
networkBytes: number;
|
||||
temperature: number | null;
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { ISystemUsageIntegration } from "../../interfaces/system-usage/system-usage-integration";
|
||||
import type { System } from "../../types";
|
||||
|
||||
export class SystemUsageMockService implements ISystemUsageIntegration {
|
||||
async getSystemsAsync(): Promise<Pick<System, "id" | "name">[]> {
|
||||
return await Promise.resolve([
|
||||
{ id: "1", name: "Mock System 1" },
|
||||
{ id: "2", name: "Mock System 2" },
|
||||
]);
|
||||
}
|
||||
async getSystemDetailsAsync(id: System["id"]): Promise<System> {
|
||||
return await Promise.resolve({
|
||||
id,
|
||||
name: `Mock System ${id}`,
|
||||
status: "up",
|
||||
agent: {
|
||||
connectionType: "webSocket",
|
||||
version: "1.0.0",
|
||||
},
|
||||
usage: {
|
||||
cpuPercentage: 25,
|
||||
memoryPercentage: 50,
|
||||
diskPercentage: 75,
|
||||
gpuPercentage: null,
|
||||
load: {
|
||||
status: "good",
|
||||
averages: {
|
||||
one: 0.5,
|
||||
five: 0.7,
|
||||
fifteen: 0.9,
|
||||
},
|
||||
},
|
||||
networkBytes: Math.floor(2 ** 24.5),
|
||||
temperature: 55,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import type { IMediaServerIntegration } from "../interfaces/media-server/media-s
|
||||
import type { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration";
|
||||
import type { NetworkControllerSummaryIntegration } from "../interfaces/network-controller-summary/network-controller-summary-integration";
|
||||
import type { ISmartHomeIntegration } from "../interfaces/smart-home/smart-home-integration";
|
||||
import type { ISystemUsageIntegration } from "../interfaces/system-usage/system-usage-integration";
|
||||
import type { IUpsSummaryIntegration } from "../interfaces/ups-summary/ups-summary-integration";
|
||||
import { CalendarMockService } from "./data/calendar";
|
||||
import { ClusterHealthMonitoringMockService } from "./data/cluster-health-monitoring";
|
||||
@@ -30,7 +29,6 @@ import { NetworkControllerSummaryMockService } from "./data/network-controller-s
|
||||
import { NotificationsMockService } from "./data/notifications";
|
||||
import { SmartHomeMockService } from "./data/smart-home";
|
||||
import { SystemHealthMonitoringMockService } from "./data/system-health-monitoring";
|
||||
import { SystemUsageMockService } from "./data/system-usage";
|
||||
import { UpsSummaryMockService } from "./data/ups-summary";
|
||||
|
||||
export class MockIntegration
|
||||
@@ -48,7 +46,6 @@ export class MockIntegration
|
||||
IMediaTranscodingIntegration,
|
||||
NetworkControllerSummaryIntegration,
|
||||
ISmartHomeIntegration,
|
||||
ISystemUsageIntegration,
|
||||
IUpsSummaryIntegration
|
||||
{
|
||||
private static readonly dnsHole = new DnsHoleMockService();
|
||||
@@ -64,7 +61,6 @@ export class MockIntegration
|
||||
private static readonly networkController = new NetworkControllerSummaryMockService();
|
||||
private static readonly notifications = new NotificationsMockService();
|
||||
private static readonly smartHome = new SmartHomeMockService();
|
||||
private static readonly systemUsage = new SystemUsageMockService();
|
||||
private static readonly upsSummary = new UpsSummaryMockService();
|
||||
|
||||
protected async testingAsync(_: IntegrationTestingInput): Promise<TestingResult> {
|
||||
@@ -134,8 +130,4 @@ export class MockIntegration
|
||||
|
||||
// UpsSummaryIntegration
|
||||
getUpsSummariesAsync = MockIntegration.upsSummary.getUpsSummariesAsync.bind(MockIntegration.upsSummary);
|
||||
|
||||
// SystemUsageIntegration
|
||||
getSystemsAsync = MockIntegration.systemUsage.getSystemsAsync.bind(MockIntegration.systemUsage);
|
||||
getSystemDetailsAsync = MockIntegration.systemUsage.getSystemDetailsAsync.bind(MockIntegration.systemUsage);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,4 +18,3 @@ export * from "./interfaces/timetable/timetable-types";
|
||||
export * from "./tracearr/tracearr-types";
|
||||
export * from "./speedtest-tracker/speedtest-tracker-types";
|
||||
export * from "./umami/umami-types";
|
||||
export * from "./interfaces/system-usage/system-usage-types";
|
||||
@@ -1,176 +0,0 @@
|
||||
import { rmdir } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import type { StartedTestContainer } from "testcontainers";
|
||||
import { GenericContainer, Wait } from "testcontainers";
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { BeszelIntegration } from "../src/beszel/beszel-integration";
|
||||
|
||||
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(),
|
||||
};
|
||||
});
|
||||
|
||||
const email = "e2e@homarr.dev";
|
||||
const password = "12341234";
|
||||
const publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID5PFut0emFoQQ53LlLGfFYA/U1Noe6WPqYN/97LJTvR";
|
||||
const token = "4af8b512-afdc-4e42-a478-d0a3bfbbb4a8";
|
||||
const IMAGE_NAME = "henrygd/beszel:latest";
|
||||
const AGENT_IMAGE_NAME = "henrygd/beszel-agent:latest";
|
||||
const systemId = "nj259tge7e4h7xy";
|
||||
|
||||
describe("Beszel integration", () => {
|
||||
let socketPathId: string;
|
||||
let beszelContainer: StartedTestContainer;
|
||||
let beszelAgentContainer: StartedTestContainer;
|
||||
|
||||
beforeAll(async () => {
|
||||
socketPathId = createId();
|
||||
beszelContainer = await createBeszelContainer(socketPathId).start();
|
||||
beszelAgentContainer = await createBeszelAgentContainer(socketPathId, beszelContainer.getMappedPort(8090)).start();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000)); // Wait for Beszel to be ready and have some system_data
|
||||
}, 20_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await beszelContainer.stop();
|
||||
await beszelAgentContainer.stop();
|
||||
await rmdir(createSocketPath(socketPathId), { recursive: true });
|
||||
}, 5_000);
|
||||
|
||||
test("Test connection is successful", async () => {
|
||||
// Arrange
|
||||
const integration = createIntegration(beszelContainer.getMappedPort(8090));
|
||||
|
||||
// Act
|
||||
const result = await integration.testConnectionAsync();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("Test connection fails with invalid credentials", async () => {
|
||||
// Arrange
|
||||
const integration = createIntegration(beszelContainer.getMappedPort(8090), {
|
||||
password: "wrongpassword",
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await integration.testConnectionAsync();
|
||||
|
||||
// Assert
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error.type).toBe("authorization");
|
||||
});
|
||||
|
||||
test("Can get systems", async () => {
|
||||
// Arrange
|
||||
const integration = createIntegration(beszelContainer.getMappedPort(8090));
|
||||
|
||||
// Act
|
||||
const systems = await integration.getSystemsAsync();
|
||||
|
||||
// Assert
|
||||
expect(systems.length).toBe(1);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expect(systems[0]!.name).toBe("localhost");
|
||||
});
|
||||
|
||||
test("Can get system details", async () => {
|
||||
// Arrange
|
||||
const integration = createIntegration(beszelContainer.getMappedPort(8090));
|
||||
|
||||
// Act
|
||||
const system = await integration.getSystemDetailsAsync(systemId);
|
||||
|
||||
// Assert
|
||||
expect(system.id).toBe(systemId);
|
||||
expect(system.name).toBe("localhost");
|
||||
expect(system.agent.connectionType).toBe("webSocket");
|
||||
expect(system.agent.version).toBeDefined();
|
||||
expect(system.status).toBe("up");
|
||||
expect(system.usage.cpuPercentage).toBeGreaterThanOrEqual(0);
|
||||
expect(system.usage.memoryPercentage).toBeGreaterThanOrEqual(0);
|
||||
expect(system.usage.diskPercentage).toBeGreaterThanOrEqual(0);
|
||||
expect(system.usage.load.averages.one).toBeGreaterThanOrEqual(0);
|
||||
expect(system.usage.networkBytes).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
const createIntegration = (port: number, overrides: { hostname?: string; password?: string } = {}) =>
|
||||
new BeszelIntegration({
|
||||
id: createId(),
|
||||
name: "Beszel E2E",
|
||||
url: `http://${overrides.hostname ?? "localhost"}:${port}`,
|
||||
externalUrl: null,
|
||||
decryptedSecrets: [
|
||||
{
|
||||
kind: "username",
|
||||
value: email,
|
||||
},
|
||||
{
|
||||
kind: "password",
|
||||
value: overrides.password ?? password,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const files = ["data.db", "data.db-shm", "data.db-wal", "id_ed25519"];
|
||||
|
||||
const createBeszelContainer = (id: string) => {
|
||||
return new GenericContainer(IMAGE_NAME)
|
||||
.withCopyFilesToContainer(
|
||||
files.map((file) => ({
|
||||
source: join(__dirname, `/volumes/beszel/${file}`),
|
||||
target: `/beszel_data/${file}`,
|
||||
})),
|
||||
)
|
||||
.withExposedPorts(8090)
|
||||
.withBindMounts([
|
||||
{
|
||||
source: createSocketPath(id),
|
||||
target: "/beszel_socket",
|
||||
mode: "rw",
|
||||
},
|
||||
])
|
||||
.withWaitStrategy(Wait.forHttp("/", 8090));
|
||||
};
|
||||
|
||||
const createBeszelAgentContainer = (id: string, port: number) => {
|
||||
return new GenericContainer(AGENT_IMAGE_NAME)
|
||||
.withBindMounts([
|
||||
{
|
||||
source: "/var/run/docker.sock",
|
||||
target: "/var/run/docker.sock",
|
||||
mode: "ro",
|
||||
},
|
||||
{
|
||||
source: createSocketPath(id),
|
||||
target: "/beszel_socket",
|
||||
mode: "rw",
|
||||
},
|
||||
])
|
||||
.withEnvironment({
|
||||
LISTEN: "/beszel_socket/beszel.sock",
|
||||
HUB_URL: `http://host.docker.internal:${port}`,
|
||||
TOKEN: token,
|
||||
KEY: publicKey,
|
||||
})
|
||||
.withExtraHosts([
|
||||
{
|
||||
// This enabled the usage of host.docker.internal as hostname in the container
|
||||
host: "host.docker.internal",
|
||||
ipAddress: "host-gateway",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const createSocketPath = (id: string) => join(__dirname, `/volumes/beszel/socket-${id}`);
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,7 +0,0 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
|
||||
c2gtZWQyNTUxOQAAACA+TxbrdHphaEEOdy5SxnxWAP1NTaHulj6mDf/eyyU70QAA
|
||||
AIgMmaMFDJmjBQAAAAtzc2gtZWQyNTUxOQAAACA+TxbrdHphaEEOdy5SxnxWAP1N
|
||||
TaHulj6mDf/eyyU70QAAAEBgdIVB4OrfE2cUkVn7lpqtyq+XRq2rxzq01U1DPHB+
|
||||
fj5PFut0emFoQQ53LlLGfFYA/U1Noe6WPqYN/97LJTvRAAAAAAECAwQF
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -1,20 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { System } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
|
||||
export const systemUsageRequestHandler = createCachedIntegrationRequestHandler<
|
||||
System,
|
||||
IntegrationKindByCategory<"systemUsage">,
|
||||
{ systemId: string }
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getSystemDetailsAsync(input.systemId);
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "seconds"),
|
||||
queryKey: "systemUsage",
|
||||
});
|
||||
@@ -811,9 +811,7 @@
|
||||
"notes": "Notes",
|
||||
"mediaMonitoring": "Monitoring",
|
||||
"speedtest": "Speed Test",
|
||||
"analytics": "Analytics",
|
||||
"systemUsage": "System Usage",
|
||||
"ups": "UPS"
|
||||
"analytics": "Analytics"
|
||||
},
|
||||
"testConnection": {
|
||||
"action": {
|
||||
@@ -3039,47 +3037,6 @@
|
||||
"internalServerError": "Failed to fetch speedtest data"
|
||||
}
|
||||
},
|
||||
"systemUsage": {
|
||||
"name": "System usage",
|
||||
"description": "CPU, Memory, Disk and other hardware usage of your system",
|
||||
"option": {
|
||||
"systemId": {
|
||||
"label": "Choose system"
|
||||
},
|
||||
"visibleItems": {
|
||||
"label": "Visible items"
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
"cpu": {
|
||||
"label": "CPU"
|
||||
},
|
||||
"memory": {
|
||||
"label": "Memory"
|
||||
},
|
||||
"disk": {
|
||||
"label": "Disk"
|
||||
},
|
||||
"gpu": {
|
||||
"label": "GPU"
|
||||
},
|
||||
"load": {
|
||||
"label": "Load Avg"
|
||||
},
|
||||
"network": {
|
||||
"label": "Net"
|
||||
},
|
||||
"temperature": {
|
||||
"label": "Temp"
|
||||
},
|
||||
"agent": {
|
||||
"label": "Agent"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"noSystem": "No system selected"
|
||||
}
|
||||
},
|
||||
"systemResources": {
|
||||
"name": "System resources",
|
||||
"description": "CPU, Memory, Disk and other hardware usage of your system",
|
||||
@@ -3899,9 +3856,6 @@
|
||||
"timetable": {
|
||||
"label": "Timetable"
|
||||
},
|
||||
"systemUsage": {
|
||||
"label": "System Usage"
|
||||
},
|
||||
"iconsUpdater": {
|
||||
"label": "Icons Updater"
|
||||
},
|
||||
|
||||
@@ -10,45 +10,3 @@ export const IconPowerOff = createCustomIcon({
|
||||
type: "outline",
|
||||
name: "power-off",
|
||||
});
|
||||
|
||||
export const IconWebsocket = createCustomIcon({
|
||||
svgContent: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 193" fill="currentColor">
|
||||
<path xmlns="http://www.w3.org/2000/svg" d="M192 145h32V68l-36-35-22 22 26 27zm32 16H113l-26-27 11-11 22 22h45l-44-45 11-11 44 44V88l-21-22 11-11-55-55H0l32 32h65l24 23-34 34-24-23V48H32v31l55 55-23 22 36 36h156z"/>
|
||||
</svg>`,
|
||||
type: "filled",
|
||||
name: "websocket",
|
||||
});
|
||||
|
||||
export const IconMemoryStick = createCustomIcon({
|
||||
svgContent: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-memory-stick-icon lucide-memory-stick">
|
||||
<path d="M6 19v-3"/>
|
||||
<path d="M10 19v-3"/>
|
||||
<path d="M14 19v-3"/>
|
||||
<path d="M18 19v-3"/>
|
||||
<path d="M8 11V9"/>
|
||||
<path d="M16 11V9"/>
|
||||
<path d="M12 11V9"/>
|
||||
<path d="M2 15h20"/>
|
||||
<path d="M2 7a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v1.1a2 2 0 0 0 0 3.837V17a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-5.1a2 2 0 0 0 0-3.837Z"/>
|
||||
</svg>`,
|
||||
type: "outline",
|
||||
name: "memory-stick",
|
||||
});
|
||||
|
||||
export const IconHardDrive = createCustomIcon({
|
||||
svgContent: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-hard-drive-icon lucide-hard-drive">
|
||||
<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>
|
||||
<line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>
|
||||
</svg>`,
|
||||
type: "outline",
|
||||
name: "hard-drive",
|
||||
});
|
||||
|
||||
export const IconGpu = createCustomIcon({
|
||||
svgContent: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" strokeWidth="2">
|
||||
<path xmlns="http://www.w3.org/2000/svg" d="M4 21V4.1a1.5 1.5 0 0 0-1.1-1L2 3m2 2h13c2.4 0 3.5 0 4.3.7s.7 2 .7 4.3v4.5c0 2.4 0 3.5-.7 4.3-.8.7-2 .7-4.3.7h-4.9a1.8 1.8 0 0 1-1.6-1c-.3-.6-1-1-1.6-1H4" />
|
||||
<path xmlns="http://www.w3.org/2000/svg" d="M19 11.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0m-11.5-3h2m-2 3h2m-2 3h2" />
|
||||
</svg>`,
|
||||
type: "outline",
|
||||
name: "gpu",
|
||||
});
|
||||
@@ -44,7 +44,6 @@ import * as speedtestTracker from "./speedtest-tracker";
|
||||
import * as stockPrice from "./stocks";
|
||||
import * as systemDisks from "./system-disks";
|
||||
import * as systemResources from "./system-resources";
|
||||
import * as systemUsage from "./system-usage";
|
||||
import * as timetable from "./timetable";
|
||||
import * as tracearr from "./tracearr";
|
||||
import * as umami from "./umami";
|
||||
@@ -54,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,
|
||||
@@ -87,7 +87,6 @@ export const widgetImports = {
|
||||
notifications,
|
||||
mediaReleases,
|
||||
systemResources,
|
||||
systemUsage,
|
||||
coolify,
|
||||
systemDisks,
|
||||
timetable,
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { Divider, Group, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
IconCpu,
|
||||
IconHourglass,
|
||||
IconNetwork,
|
||||
IconSquareChevronRight,
|
||||
IconThermometer,
|
||||
IconWifi,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { humanFileSize, objectEntries } from "@homarr/common";
|
||||
import type { System, SystemLoadStatus, SystemStatus } from "@homarr/integrations/types";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { IconGpu, IconHardDrive, IconMemoryStick, IconWebsocket } from "@homarr/ui/icons";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { NoIntegrationSelectedError } from "../errors";
|
||||
import { NoSystemSelectedError } from "./errors/no-system-selected-error";
|
||||
import { Dot, Item, ProgressValue } from "./item";
|
||||
|
||||
export default function SystemUsage({ integrationIds, options, isEditMode }: WidgetComponentProps<"systemUsage">) {
|
||||
if (isEditMode) {
|
||||
return <SystemUsagePreview visibleItems={options.visibleItems} />;
|
||||
}
|
||||
|
||||
const integrationId = integrationIds.at(0);
|
||||
if (!integrationId) {
|
||||
throw new NoIntegrationSelectedError();
|
||||
}
|
||||
|
||||
if (!options.systemId) {
|
||||
throw new NoSystemSelectedError();
|
||||
}
|
||||
|
||||
return (
|
||||
<SystemUsageContent
|
||||
systemId={options.systemId.value}
|
||||
integrationId={integrationId}
|
||||
visibleItems={options.visibleItems}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface SystemUsagePreviewProps {
|
||||
visibleItems: (keyof typeof items)[];
|
||||
}
|
||||
|
||||
const progressItems = new Set<keyof typeof items>(["cpu", "memory", "disk", "gpu"]);
|
||||
|
||||
const SystemUsagePreview = ({ visibleItems }: SystemUsagePreviewProps) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Stack gap="sm" p="sm">
|
||||
<Group gap="sm" align="center">
|
||||
<Dot color="green" />
|
||||
<Text fw="bold" size="sm">
|
||||
System Name
|
||||
</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
|
||||
{objectEntries(items).map(([key, item]) =>
|
||||
("hidden" in item && item.hidden({ usage: { gpuPercentage: null, temperature: null } } as System)) ||
|
||||
!visibleItems.includes(key) ? null : (
|
||||
<Item key={key} icon={item.icon} label={t(`widget.systemUsage.item.${key}.label`)}>
|
||||
{progressItems.has(key) ? <ProgressValue value={50} /> : <Text size="xs">—</Text>}
|
||||
</Item>
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface SystemUsageContentProps {
|
||||
systemId: string;
|
||||
integrationId: string;
|
||||
visibleItems: (keyof typeof items)[];
|
||||
}
|
||||
|
||||
const SystemUsageContent = ({ systemId, integrationId, visibleItems }: SystemUsageContentProps) => {
|
||||
const [systemDetails] = clientApi.widget.systemUsage.get.useSuspenseQuery(
|
||||
{ systemId, integrationId },
|
||||
{
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
);
|
||||
const utils = clientApi.useUtils();
|
||||
clientApi.widget.systemUsage.subscribe.useSubscription(
|
||||
{ systemId, integrationId },
|
||||
{
|
||||
onData(data) {
|
||||
utils.widget.systemUsage.get.setData({ systemId, integrationId }, data.system);
|
||||
},
|
||||
},
|
||||
);
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Stack gap="sm" p="sm">
|
||||
<Group gap="sm" align="center">
|
||||
<Dot color={systemStatusColors[systemDetails.status]} />
|
||||
<Text fw="bold" size="sm">
|
||||
{systemDetails.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
|
||||
{objectEntries(items).map(([key, item]) =>
|
||||
("hidden" in item && item.hidden(systemDetails)) || !visibleItems.includes(key) ? null : (
|
||||
<Item key={key} icon={item.icon} label={t(`widget.systemUsage.item.${key}.label`)}>
|
||||
<item.component system={systemDetails} />
|
||||
</Item>
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const systemStatusColors = {
|
||||
up: "green",
|
||||
down: "red",
|
||||
paused: "gray",
|
||||
pending: "yellow",
|
||||
} satisfies Record<SystemStatus, MantineColor>;
|
||||
|
||||
const loadStatusColors = {
|
||||
good: "green",
|
||||
warning: "yellow",
|
||||
critical: "red",
|
||||
unknown: "gray",
|
||||
} satisfies Record<SystemLoadStatus, MantineColor>;
|
||||
|
||||
const items = {
|
||||
cpu: {
|
||||
icon: IconCpu,
|
||||
component({ system }) {
|
||||
return <ProgressValue value={system.usage.cpuPercentage} />;
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
icon: IconMemoryStick,
|
||||
component({ system }) {
|
||||
return <ProgressValue value={system.usage.memoryPercentage} />;
|
||||
},
|
||||
},
|
||||
disk: {
|
||||
icon: IconHardDrive,
|
||||
component({ system }) {
|
||||
return <ProgressValue value={system.usage.diskPercentage} />;
|
||||
},
|
||||
},
|
||||
gpu: {
|
||||
icon: IconGpu,
|
||||
component({ system }) {
|
||||
return <ProgressValue value={system.usage.gpuPercentage ?? 0} />;
|
||||
},
|
||||
hidden(system) {
|
||||
return system.usage.gpuPercentage === null;
|
||||
},
|
||||
},
|
||||
load: {
|
||||
icon: IconHourglass,
|
||||
component({ system }) {
|
||||
return (
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
<Dot color={loadStatusColors[system.usage.load.status]} />
|
||||
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
|
||||
{system.usage.load.averages.one}
|
||||
</Text>
|
||||
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
|
||||
{system.usage.load.averages.five}
|
||||
</Text>
|
||||
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
|
||||
{system.usage.load.averages.fifteen}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
network: {
|
||||
icon: IconNetwork,
|
||||
component({ system }) {
|
||||
return (
|
||||
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
|
||||
{humanFileSize(system.usage.networkBytes, "/s")}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
temperature: {
|
||||
icon: IconThermometer,
|
||||
component({ system }) {
|
||||
return (
|
||||
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
|
||||
{system.usage.temperature ?? "N/A"} °C
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
hidden(system) {
|
||||
return system.usage.temperature === null;
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
icon: IconWifi,
|
||||
component({ system }) {
|
||||
return (
|
||||
<Group gap="xs" align="center" wrap="nowrap">
|
||||
{system.agent.connectionType === "ssh" ? <IconSquareChevronRight size={14} stroke={1.5} /> : null}
|
||||
{system.agent.connectionType === "webSocket" ? <IconWebsocket size={14} stroke={1.5} /> : null}
|
||||
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
|
||||
{system.agent.version}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
} satisfies Record<
|
||||
string,
|
||||
{ icon: TablerIcon; component: (props: { system: System }) => React.ReactNode; hidden?: (system: System) => boolean }
|
||||
>;
|
||||
@@ -1,19 +0,0 @@
|
||||
import { IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import type { TranslationFunction } from "@homarr/translation";
|
||||
|
||||
import { ErrorBoundaryError } from "../../errors";
|
||||
|
||||
export class NoSystemSelectedError extends ErrorBoundaryError {
|
||||
constructor() {
|
||||
super("No system selected");
|
||||
}
|
||||
|
||||
public getErrorBoundaryData() {
|
||||
return {
|
||||
icon: IconServerOff,
|
||||
message: (t: TranslationFunction) => t("widget.systemUsage.error.noSystem"),
|
||||
showLogsLink: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { IconServer } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("systemUsage", {
|
||||
icon: IconServer,
|
||||
supportedIntegrations: getIntegrationKindsByCategory("systemUsage"),
|
||||
integrationsRequired: false,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
systemId: factory.dynamicSelect({
|
||||
useOptions(_query, integrationIds) {
|
||||
const { data: systems, isPending } = clientApi.widget.systemUsage.listSystems.useQuery(
|
||||
{ integrationId: integrationIds[0] ?? "" },
|
||||
{
|
||||
enabled: integrationIds.length > 0,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
isPending,
|
||||
options:
|
||||
systems?.map((system) => ({
|
||||
label: system.name,
|
||||
value: system.id,
|
||||
})) ?? [],
|
||||
};
|
||||
},
|
||||
}),
|
||||
visibleItems: factory.multiSelect({
|
||||
options: (["cpu", "memory", "disk", "gpu", "load", "network", "temperature", "agent"] as const).map((key) => ({
|
||||
value: key,
|
||||
label: (t) => t(`widget.systemUsage.item.${key}.label`),
|
||||
})),
|
||||
defaultValue: ["cpu", "memory", "disk", "gpu", "load", "network", "temperature", "agent"],
|
||||
}),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { Box, Flex, Group, Progress, Text } from "@mantine/core";
|
||||
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
export const ProgressValue = ({ value }: { value: number }) => (
|
||||
<Group gap="xs" align="center" w="100%" wrap="nowrap">
|
||||
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
|
||||
{value}%
|
||||
</Text>
|
||||
<Progress value={value} color={progressColor(value)} w="100%" />
|
||||
</Group>
|
||||
);
|
||||
|
||||
const progressColor = (value: number): MantineColor => {
|
||||
if (value < 50) return "green";
|
||||
if (value < 75) return "yellow";
|
||||
return "red";
|
||||
};
|
||||
|
||||
interface DotProps {
|
||||
color: MantineColor;
|
||||
}
|
||||
|
||||
export const Dot = ({ color }: DotProps) => <Box style={{ borderRadius: "100%" }} bg={color} h={8} w={8}></Box>;
|
||||
|
||||
interface ItemProps {
|
||||
icon: TablerIcon;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Item = (props: ItemProps) => (
|
||||
<Group justify="space-between" align="center" wrap="nowrap" w="100%">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<props.icon size={16} stroke={1.5} />
|
||||
<Text size="xs" style={{ whiteSpace: "nowrap" }}>
|
||||
{props.label}:
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Flex c="white" justify="end" maw="50%" w="100%">
|
||||
{props.children}
|
||||
</Flex>
|
||||
</Group>
|
||||
);
|
||||
Generated
+562
-536
File diff suppressed because it is too large.
Load diff
+32
-32
@@ -21,17 +21,17 @@ catalog:
|
||||
"@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
|
||||
"@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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user