Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e742ef5d0f | ||
|
|
11865cbe8a | ||
|
|
fcdc36766a | ||
|
|
d5373f1c58 | ||
|
|
e267a1bb72 | ||
|
|
eb7c161caf | ||
|
|
4b8956449f | ||
|
|
3d1f2e7764 | ||
|
|
75a45c2b71 | ||
|
|
62a0bae989 | ||
|
|
1220fbf6ad | ||
|
|
3da104256f | ||
|
|
60bf867203 | ||
|
|
1279168538 | ||
|
|
97e9c3258e | ||
|
|
8e6f31e92a | ||
|
|
acd248cca2 | ||
|
|
2190436d9f | ||
|
|
48d157b4f4 | ||
|
|
f773b4a15e | ||
|
|
cb5b73ddbd | ||
|
|
df56eeab95 |
No files matched your search
@@ -33,7 +33,6 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.64.0
|
||||
- 1.62.0
|
||||
- 1.61.0
|
||||
- 1.60.1-beta.1
|
||||
|
||||
@@ -1,67 +1,3 @@
|
||||
## [1.64.0](https://github.com/homarr-labs/homarr/compare/v1.63.0...v1.64.0) (2026-06-05)
|
||||
|
||||
### Features
|
||||
|
||||
* add comprehensive demo mode with mock integrations ([#5870](https://github.com/homarr-labs/homarr/issues/5870)) ([0a64679](https://github.com/homarr-labs/homarr/commit/0a6467979a5e3b3e3eae6252c31fca1d9af54dc1))
|
||||
* add PeaNUT integration and UPS monitoring widget ([#5862](https://github.com/homarr-labs/homarr/issues/5862)) ([0dc2558](https://github.com/homarr-labs/homarr/commit/0dc2558fafedcbba246c2e968d00cc541ab9f84e))
|
||||
* **docker:** add container logs modal and stats columns ([#4807](https://github.com/homarr-labs/homarr/issues/4807)) ([dbf021e](https://github.com/homarr-labs/homarr/commit/dbf021e04a785b44ad0e2ce45634de371d76de6d))
|
||||
* media request search as first-class spotlight mode ([#5823](https://github.com/homarr-labs/homarr/issues/5823)) ([3d3e305](https://github.com/homarr-labs/homarr/commit/3d3e3059a98b36b6e64e3aad90f715e334baff3e))
|
||||
* **onboarding:** enhance integration setup with Docker discovery and URL templating ([#5607](https://github.com/homarr-labs/homarr/issues/5607)) ([dd7a490](https://github.com/homarr-labs/homarr/commit/dd7a4908a36a71c1d0d1e41524eb8fd9bf53ca4d))
|
||||
* **onboarding:** guided management and board tours ([#5756](https://github.com/homarr-labs/homarr/issues/5756)) ([0b663a7](https://github.com/homarr-labs/homarr/commit/0b663a7fc6c017b9fbe78c019f06d46ee95f165f))
|
||||
* **widgets:** add in-place app editing in widget edit modal ([#5809](https://github.com/homarr-labs/homarr/issues/5809)) ([99346f2](https://github.com/homarr-labs/homarr/commit/99346f288bcfc701c41fecaab6f7bc0438d80563))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add bookmark widget option to toggle card borders ([#5854](https://github.com/homarr-labs/homarr/issues/5854)) ([41fe592](https://github.com/homarr-labs/homarr/commit/41fe5928f19207d02b04e64c2caac49d896f72ce))
|
||||
* **auth:** relax password complexity requirements to suggestions-only ([#5764](https://github.com/homarr-labs/homarr/issues/5764)) ([fd10788](https://github.com/homarr-labs/homarr/commit/fd107888385557ededcf51576c6320a1960690f2))
|
||||
* **db:** log when UNSAFE_ENABLE_MOCK_INTEGRATION is enabled during seeding ([efd37dc](https://github.com/homarr-labs/homarr/commit/efd37dca93be7f5f362331e9b5c2a95a87e24379))
|
||||
* disable onboarding tours when demo mode is enabled ([68f24e3](https://github.com/homarr-labs/homarr/commit/68f24e34d580e3a155c37ca37c3ffd61f296bd0c))
|
||||
* **docker:** restore container selection toolbar broken by Mantine v9 ([#5855](https://github.com/homarr-labs/homarr/issues/5855)) ([fe4598f](https://github.com/homarr-labs/homarr/commit/fe4598f7221a2861b8c5ae0cfd267e8c4fbf48df))
|
||||
* **immich:** respect app color scheme in server-stats widget ([#5859](https://github.com/homarr-labs/homarr/issues/5859)) ([2c6b7a8](https://github.com/homarr-labs/homarr/commit/2c6b7a818e9d32b414b024d711a3b64533e61cb9))
|
||||
* redirect to login after credentials logout instead of crashing on current page ([#5819](https://github.com/homarr-labs/homarr/issues/5819)) ([cc24f07](https://github.com/homarr-labs/homarr/commit/cc24f070db26337a4a2d87d68c7f142ed1118584))
|
||||
* sync docs sitemap and remove stale coolify ts-expect-error ([4f5f3e1](https://github.com/homarr-labs/homarr/commit/4f5f3e14a129a94295d4b8ffb7d3702fdb4f6a74))
|
||||
* **ui:** restore pre-v9 light variant colors ([#5856](https://github.com/homarr-labs/homarr/issues/5856)) ([a755692](https://github.com/homarr-labs/homarr/commit/a755692d81b80b5ff83051a3b7261c01ac10321b))
|
||||
|
||||
## [1.63.0](https://github.com/homarr-labs/homarr/compare/v1.62.0...v1.63.0) (2026-05-29)
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** support custom token endpoint auth methods ([#5806](https://github.com/homarr-labs/homarr/issues/5806)) ([be61602](https://github.com/homarr-labs/homarr/commit/be61602576a8bec4bb7e4928d36e0854e926264d))
|
||||
* auto-open widget settings on add with all integrations pre-selected ([#5767](https://github.com/homarr-labs/homarr/issues/5767)) ([f10bb3a](https://github.com/homarr-labs/homarr/commit/f10bb3ae23fefc9779332733706aa567cc40ba48))
|
||||
* **board:** persist category collapse state in localStorage for guests ([#5714](https://github.com/homarr-labs/homarr/issues/5714)) ([66212b4](https://github.com/homarr-labs/homarr/commit/66212b4e475738e61d2ea78f3a7e72d7a610bf12)), closes [#3355](https://github.com/homarr-labs/homarr/issues/3355)
|
||||
* **manage:** unified management page layout ([#5758](https://github.com/homarr-labs/homarr/issues/5758)) ([c252b4f](https://github.com/homarr-labs/homarr/commit/c252b4f9accc4523f222354c73f687714640701f))
|
||||
* **users:** rework general settings ([#4915](https://github.com/homarr-labs/homarr/issues/4915)) ([2991777](https://github.com/homarr-labs/homarr/commit/2991777b0a0ae72522d0769a86a91250d618dbe6))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* matine ui fixes for concistency ([#5733](https://github.com/homarr-labs/homarr/issues/5733)) ([4454e3f](https://github.com/homarr-labs/homarr/commit/4454e3fb7afa624ee2f1eaec015f2725e664ac46))
|
||||
* parse Speedtest Tracker created_at as local time ([#5751](https://github.com/homarr-labs/homarr/issues/5751)) ([b19549b](https://github.com/homarr-labs/homarr/commit/b19549b601636bcbc2fadaa270a77a9fdb48b77a))
|
||||
* prevent GitHub releases check from blocking app on API failure ([#5807](https://github.com/homarr-labs/homarr/issues/5807)) ([6cc2ac5](https://github.com/homarr-labs/homarr/commit/6cc2ac56a920563c47aaaab1b33b19a9b6d5ecee))
|
||||
* **ui:** use imported theme instead of undefined createTheme ([4a67bf0](https://github.com/homarr-labs/homarr/commit/4a67bf0ef30ff8d10b06d2d3719ccab792395669))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **boards:** parallelize board page data fetching ([#5736](https://github.com/homarr-labs/homarr/issues/5736)) ([8f03a38](https://github.com/homarr-labs/homarr/commit/8f03a38de5b9b385da7ffcc9a963be00710acb5e))
|
||||
|
||||
## [1.62.0](https://github.com/homarr-labs/homarr/compare/v1.61.1...v1.62.0) (2026-05-22)
|
||||
|
||||
### Features
|
||||
|
||||
* **seed:** add demo mode with pre-seeded user ([#5739](https://github.com/homarr-labs/homarr/issues/5739)) ([dade477](https://github.com/homarr-labs/homarr/commit/dade477b3226765eb614e66588ebee84c45cb688))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** update dependency better-sqlite3 to ^12.10.0 ([#5727](https://github.com/homarr-labs/homarr/issues/5727)) ([044ad9e](https://github.com/homarr-labs/homarr/commit/044ad9edf9c1dc9b50b9591dea0b5f0cce4be01a))
|
||||
* **docker:** improve icon matching ([#5693](https://github.com/homarr-labs/homarr/issues/5693)) ([7cd30b6](https://github.com/homarr-labs/homarr/commit/7cd30b640cbf47e4678abda18f83c0924f039b1b))
|
||||
* exclude .env files from Docker build context ([#5738](https://github.com/homarr-labs/homarr/issues/5738)) ([fa73763](https://github.com/homarr-labs/homarr/commit/fa737638a812372d904207fe7af5bd7c6f53d159))
|
||||
* **tracearr:** support absolute image urls ([#5654](https://github.com/homarr-labs/homarr/issues/5654)) ([7a12721](https://github.com/homarr-labs/homarr/commit/7a1272146c597c1f9df89d74b8389f70e7cdd61a))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* (next branch) reduce idle memory on startup ([#5637](https://github.com/homarr-labs/homarr/issues/5637)) ([5ee84f8](https://github.com/homarr-labs/homarr/commit/5ee84f8cf464c308729281cb4db572950ba85a82))
|
||||
* **boards:** remove loading overlay and show board immediately ([#5735](https://github.com/homarr-labs/homarr/issues/5735)) ([df4b57b](https://github.com/homarr-labs/homarr/commit/df4b57bd414d4402522f9210b1470d0561c37274))
|
||||
* **memory:** combine 3 node processes into one ([#5600](https://github.com/homarr-labs/homarr/issues/5600)) ([a1aa550](https://github.com/homarr-labs/homarr/commit/a1aa5505a365688b456090a882770e561479833e))
|
||||
|
||||
## [1.61.1](https://github.com/homarr-labs/homarr/compare/v1.61.0...v1.61.1) (2026-05-18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { MantineColor } from "@mantine/core";
|
||||
import { ActionIcon, Avatar, Badge, Box, Button, Group, Menu, Text, Tooltip } from "@mantine/core";
|
||||
import { Avatar, Badge, Box, Button, Group, Text } from "@mantine/core";
|
||||
import {
|
||||
IconCategoryPlus,
|
||||
IconDots,
|
||||
IconFileText,
|
||||
IconPlayerPlay,
|
||||
IconPlayerStop,
|
||||
IconRefresh,
|
||||
IconRotateClockwise,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table";
|
||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||
import { MantineReactTable } from "mantine-react-table";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { humanFileSize, useTimeAgo } from "@homarr/common";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import type { ContainerState } from "@homarr/docker";
|
||||
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
||||
import { containerStateColorMap } from "@homarr/docker/shared";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
@@ -30,30 +27,25 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
import { OverflowBadge } from "@homarr/ui";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
|
||||
type DockerContainer = RouterOutputs["docker"]["getContainers"]["containers"][number];
|
||||
|
||||
const createContainerLogsPath = (container: Pick<DockerContainer, "id" | "name">) =>
|
||||
`/manage/tools/docker/logs/${container.id}?name=${encodeURIComponent(container.name)}`;
|
||||
|
||||
const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[] => [
|
||||
const createColumns = (
|
||||
t: TranslationFunction,
|
||||
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: t("docker.field.name.label"),
|
||||
Cell({ renderedCellValue, row }) {
|
||||
return (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Group gap="xs">
|
||||
<Avatar
|
||||
variant="outline"
|
||||
radius="sm"
|
||||
radius="lg"
|
||||
size="md"
|
||||
styles={{ image: { objectFit: "contain" } }}
|
||||
src={row.original.iconUrl}
|
||||
>
|
||||
{row.original.name.at(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
<Text truncate="end" maw={500}>
|
||||
{renderedCellValue}
|
||||
</Text>
|
||||
<Text>{renderedCellValue}</Text>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
@@ -84,11 +76,11 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
|
||||
maxSize: 200,
|
||||
Cell({ renderedCellValue, cell }) {
|
||||
return (
|
||||
<Tooltip label={cell.row.original.image} multiline maw={400}>
|
||||
<Box maw={200}>
|
||||
<Text truncate="end">{renderedCellValue}</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Box maw={200}>
|
||||
<Text truncate="end" title={cell.row.original.image}>
|
||||
{renderedCellValue}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -103,57 +95,13 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cpuUsage",
|
||||
accessorKey: "cpuUsage",
|
||||
size: 120,
|
||||
header: t("docker.field.stats.cpu.label"),
|
||||
enableHiding: true,
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const cpuUsageA = safeValue(rowA.original.cpuUsage);
|
||||
const cpuUsageB = safeValue(rowB.original.cpuUsage);
|
||||
|
||||
return cpuUsageA - cpuUsageB;
|
||||
},
|
||||
Cell({ row }) {
|
||||
const cpuUsage = safeValue(row.original.cpuUsage);
|
||||
|
||||
return (
|
||||
<Text size="sm" c={cpuUsageColor(cpuUsage, row.original.state)}>
|
||||
{cpuUsage.toFixed(2)}%
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "memoryUsage",
|
||||
accessorKey: "memoryUsage",
|
||||
size: 140,
|
||||
header: t("docker.field.stats.memory.label"),
|
||||
enableHiding: true,
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const memoryUsageA = safeValue(rowA.original.memoryUsage);
|
||||
const memoryUsageB = safeValue(rowB.original.memoryUsage);
|
||||
|
||||
return memoryUsageA - memoryUsageB;
|
||||
},
|
||||
Cell({ row }) {
|
||||
const bytesUsage = safeValue(row.original.memoryUsage);
|
||||
|
||||
return (
|
||||
<Text size="sm" c={memoryUsageColor(bytesUsage, row.original.state)}>
|
||||
{humanFileSize(bytesUsage)}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
|
||||
export function DockerTable(initialData: RouterOutputs["docker"]["getContainers"]) {
|
||||
const t = useI18n();
|
||||
const tDocker = useScopedI18n("docker");
|
||||
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
initialData: { containers, timestamp },
|
||||
initialData,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
@@ -183,8 +131,6 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
||||
enableColumnFilters: false,
|
||||
enablePagination: false,
|
||||
enableRowSelection: true,
|
||||
enableRowActions: true,
|
||||
positionActionsColumn: "last",
|
||||
positionToolbarAlertBanner: "top",
|
||||
enableTableFooter: false,
|
||||
enableBottomToolbar: false,
|
||||
@@ -195,10 +141,7 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
||||
autoFocus: true,
|
||||
},
|
||||
|
||||
initialState: { density: "xs", showGlobalFilter: true, columnVisibility: { cpuUsage: false, memoryUsage: false } },
|
||||
mantineTableBodyCellProps: { style: { padding: "4px 8px" } },
|
||||
mantineTableHeadCellProps: { style: { padding: "4px 8px" } },
|
||||
renderRowActions: ({ row }: { row: MRT_Row<DockerContainer> }) => <ContainerRowMenu container={row.original} />,
|
||||
initialState: { density: "xs", showGlobalFilter: true },
|
||||
renderTopToolbarCustomActions: () => (
|
||||
<Button variant="default" rightSection={<IconRefresh size="1rem" />} onClick={() => mutate()} loading={isPending}>
|
||||
{tDocker("action.refresh.label")}
|
||||
@@ -231,104 +174,28 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
||||
);
|
||||
}
|
||||
|
||||
const containerActions: { action: "start" | "stop" | "restart" | "remove"; icon: TablerIcon; color: MantineColor }[] = [
|
||||
{ action: "start", icon: IconPlayerPlay, color: "green" },
|
||||
{ action: "stop", icon: IconPlayerStop, color: "red" },
|
||||
{ action: "restart", icon: IconRotateClockwise, color: "orange" },
|
||||
{ action: "remove", icon: IconTrash, color: "red" },
|
||||
];
|
||||
|
||||
const ContainerRowMenu = ({ container }: { container: DockerContainer }) => {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const router = useRouter();
|
||||
const utils = clientApi.useUtils();
|
||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||
|
||||
const useContainerAction = (action: "start" | "stop" | "restart" | "remove") =>
|
||||
clientApi.docker[`${action}All`].useMutation({
|
||||
async onSettled() {
|
||||
await utils.docker.getContainers.invalidate();
|
||||
},
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t(`${action}.notification.success.title`),
|
||||
message: t(`${action}.notification.success.message`),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t(`${action}.notification.error.title`),
|
||||
message: t(`${action}.notification.error.message`),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const mutations = {
|
||||
start: useContainerAction("start"),
|
||||
stop: useContainerAction("stop"),
|
||||
restart: useContainerAction("restart"),
|
||||
remove: useContainerAction("remove"),
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu withinPortal position="bottom-end" shadow="sm">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={(e) => e.stopPropagation()}>
|
||||
<IconDots size="1rem" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconFileText size="1rem" />}
|
||||
onClick={() => router.push(createContainerLogsPath(container))}
|
||||
>
|
||||
{t("logs.label")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{containerActions.map(({ action, icon: Icon, color }) => (
|
||||
<Menu.Item
|
||||
key={action}
|
||||
leftSection={<Icon size="1rem" />}
|
||||
color={color}
|
||||
onClick={() => mutations[action].mutate({ ids: [container.id] })}
|
||||
>
|
||||
{t(`${action}.label`)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<IconCategoryPlus size="1rem" />}
|
||||
color="red"
|
||||
onClick={() => openModal({ selectedContainers: [container] })}
|
||||
>
|
||||
{t("addToHomarr.label")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
interface ContainerActionBarProps {
|
||||
selectedContainers: DockerContainer[];
|
||||
selectedContainers: RouterOutputs["docker"]["getContainers"]["containers"];
|
||||
}
|
||||
|
||||
const ContainerActionBar = ({ selectedContainers }: ContainerActionBarProps) => {
|
||||
const t = useScopedI18n("docker.action");
|
||||
const { openModal } = useModalAction(AddDockerAppToHomarr);
|
||||
const handleClick = () => {
|
||||
openModal({
|
||||
selectedContainers,
|
||||
});
|
||||
};
|
||||
|
||||
const selectedIds = selectedContainers.map((container) => container.id);
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
{containerActions.map(({ action, icon, color }) => (
|
||||
<ContainerActionBarButton key={action} icon={icon} color={color} action={action} selectedIds={selectedIds} />
|
||||
))}
|
||||
<Button
|
||||
leftSection={<IconCategoryPlus />}
|
||||
color="red"
|
||||
onClick={() => openModal({ selectedContainers })}
|
||||
variant="light"
|
||||
>
|
||||
<ContainerActionBarButton icon={IconPlayerPlay} color="green" action="start" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconPlayerStop} color="red" action="stop" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconRotateClockwise} color="orange" action="restart" selectedIds={selectedIds} />
|
||||
<ContainerActionBarButton icon={IconTrash} color="red" action="remove" selectedIds={selectedIds} />
|
||||
<Button leftSection={<IconCategoryPlus />} color={"red"} onClick={handleClick} variant="light">
|
||||
{t("addToHomarr.label")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export const ClientSideDockerLogsTerminal = dynamic(
|
||||
() => import("./terminal").then(({ DockerLogsTerminal }) => DockerLogsTerminal),
|
||||
{
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
@@ -1,47 +0,0 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Box, Group } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
|
||||
import { ClientSideDockerLogsTerminal } from "./client";
|
||||
|
||||
interface DockerContainerLogsPageProps {
|
||||
params: Promise<{ containerId: string }>;
|
||||
searchParams: Promise<{ name?: string }>;
|
||||
}
|
||||
|
||||
export default async function DockerContainerLogsPage({ params, searchParams }: DockerContainerLogsPageProps) {
|
||||
const session = await auth();
|
||||
if (!(session?.user.permissions.includes("admin") && env.ENABLE_DOCKER)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { containerId } = await params;
|
||||
const { name } = await searchParams;
|
||||
|
||||
try {
|
||||
await api.docker.logs({ id: containerId, tail: 1 });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<DynamicBreadcrumb
|
||||
dynamicMappings={new Map([[containerId, name ?? containerId]])}
|
||||
nonInteractable={[containerId]}
|
||||
/>
|
||||
</Group>
|
||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||
<ClientSideDockerLogsTerminal containerId={containerId} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
.outerTerminal > div {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import classes from "./terminal.module.css";
|
||||
|
||||
interface DockerLogsTerminalProps {
|
||||
containerId: string;
|
||||
}
|
||||
|
||||
export const DockerLogsTerminal = ({ containerId }: DockerLogsTerminalProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const terminalRef = useRef<Terminal | null>(null);
|
||||
const pendingLogsRef = useRef<string[]>([]);
|
||||
const isTerminalReadyRef = useRef(false);
|
||||
|
||||
const writeToTerminal = useCallback((data: string) => {
|
||||
if (!isTerminalReadyRef.current || !terminalRef.current) {
|
||||
pendingLogsRef.current.push(data);
|
||||
return;
|
||||
}
|
||||
|
||||
terminalRef.current.write(data);
|
||||
terminalRef.current.refresh(0, terminalRef.current.rows - 1);
|
||||
}, []);
|
||||
|
||||
clientApi.docker.subscribeLogs.useSubscription(
|
||||
{ id: containerId, tail: 200 },
|
||||
{
|
||||
onData: writeToTerminal,
|
||||
onError(err) {
|
||||
writeToTerminal(`\r\n\x1b[31m${err.message}\x1b[0m`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const canvasAddon = new CanvasAddon();
|
||||
|
||||
terminalRef.current = new Terminal({
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
convertEol: true,
|
||||
fontSize: 14,
|
||||
});
|
||||
terminalRef.current.open(ref.current);
|
||||
terminalRef.current.loadAddon(canvasAddon);
|
||||
|
||||
isTerminalReadyRef.current = true;
|
||||
const terminal = terminalRef.current;
|
||||
pendingLogsRef.current.forEach((data) => terminal.write(data));
|
||||
pendingLogsRef.current = [];
|
||||
terminal.refresh(0, terminal.rows - 1);
|
||||
|
||||
setTimeout(() => {
|
||||
const fitAddon = new FitAddon();
|
||||
terminalRef.current?.loadAddon(fitAddon);
|
||||
fitAddon.fit();
|
||||
});
|
||||
|
||||
return () => {
|
||||
isTerminalReadyRef.current = false;
|
||||
pendingLogsRef.current = [];
|
||||
canvasAddon.dispose();
|
||||
terminalRef.current?.dispose();
|
||||
terminalRef.current = null;
|
||||
};
|
||||
}, [writeToTerminal]);
|
||||
|
||||
return <Box ref={ref} className={classes.outerTerminal} h="100%" />;
|
||||
};
|
||||
@@ -57,9 +57,8 @@ export const TerminalComponent = () => {
|
||||
});
|
||||
|
||||
return () => {
|
||||
canvasAddon.dispose();
|
||||
terminalRef.current?.dispose();
|
||||
terminalRef.current = null;
|
||||
canvasAddon.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { ManageContainer } from "~/components/manage/manage-container";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { NavigationLink } from "../groups/[id]/_navigation";
|
||||
import { canAccessUserEditPage } from "./access";
|
||||
|
||||
@@ -26,7 +26,7 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
|
||||
const session = await auth();
|
||||
const t = await getI18n();
|
||||
const tUser = await getScopedI18n("management.page.user");
|
||||
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound).catch(catchTrpcUnauthorized);
|
||||
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound);
|
||||
|
||||
if (!canAccessUserEditPage(session, user.id)) {
|
||||
notFound();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { catchTrpcNotFound, catchTrpcUnauthorized } from "~/errors/trpc-catch-error";
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
|
||||
import { canAccessUserEditPage } from "../access";
|
||||
import { ChangePasswordForm } from "./_components/_change-password-form";
|
||||
|
||||
@@ -23,8 +23,7 @@ export default async function UserSecurityPage(props: Props) {
|
||||
.getById({
|
||||
userId: params.userId,
|
||||
})
|
||||
.catch(catchTrpcNotFound)
|
||||
.catch(catchTrpcUnauthorized);
|
||||
.catch(catchTrpcNotFound);
|
||||
|
||||
if (!canAccessUserEditPage(session, user.id)) {
|
||||
notFound();
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
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`);
|
||||
};
|
||||
@@ -1,116 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
"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,12 +2,8 @@ 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";
|
||||
@@ -15,7 +11,6 @@ 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
|
||||
@@ -23,7 +18,7 @@ export const SectionContent = () => {
|
||||
* @see https://github.com/homarr-labs/homarr/pull/1770
|
||||
*/
|
||||
const sortedItems = useMemo(() => {
|
||||
return [...items, ...innerSections].toSorted((itemA, itemB) => {
|
||||
return [...items, ...innerSections].sort((itemA, itemB) => {
|
||||
if (itemA.yOffset === itemB.yOffset) {
|
||||
return itemA.xOffset - itemB.xOffset;
|
||||
}
|
||||
@@ -35,7 +30,7 @@ export const SectionContent = () => {
|
||||
return (
|
||||
<>
|
||||
{sortedItems.map((item) => (
|
||||
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} integrations={integrations} />
|
||||
<Item key={item.id} item={item} innerRef={refs.items.current[item.id]} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@@ -44,12 +39,12 @@ export const SectionContent = () => {
|
||||
interface ItemProps {
|
||||
item: DynamicSectionItem | SectionItem;
|
||||
innerRef: React.RefObject<GridItemHTMLElement | null> | undefined;
|
||||
integrations: RouterOutputs["integration"]["all"] | undefined;
|
||||
}
|
||||
|
||||
const Item = ({ item, innerRef, integrations }: ItemProps) => {
|
||||
const Item = ({ item, innerRef }: ItemProps) => {
|
||||
const minWidth = useMinSize(item, "x");
|
||||
const minHeight = useMinSize(item, "y");
|
||||
|
||||
return (
|
||||
<GridStackItem
|
||||
key={item.id}
|
||||
@@ -64,7 +59,6 @@ const Item = ({ item, innerRef, integrations }: 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>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
label={props.label}
|
||||
leftSection={props.icon}
|
||||
href={props.href}
|
||||
active={pathname === props.href || pathname.startsWith(`${props.href}/`)}
|
||||
active={pathname === props.href}
|
||||
/>
|
||||
);
|
||||
return <TourTarget id={tourId}>{link}</TourTarget>;
|
||||
@@ -34,7 +34,7 @@ const NavLinkHref = (props: NavigationLinkHref) => {
|
||||
|
||||
const NavLinkWithItems = (props: NavigationLinkWithItems) => {
|
||||
const pathname = usePathname();
|
||||
const isActive = props.items.some((item) => pathname === item.href || pathname.startsWith(`${item.href}/`));
|
||||
const isActive = props.items.some((item) => item.href === pathname);
|
||||
const nav = (
|
||||
<NavLink label={props.label} leftSection={props.icon} defaultOpened={isActive}>
|
||||
{props.items.map((item) => (
|
||||
|
||||
@@ -58,23 +58,19 @@ export const DynamicBreadcrumb = ({
|
||||
}
|
||||
const href = `/${pathnameParts.slice(0, index + 1).join("/")}`;
|
||||
const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`;
|
||||
const mappedValue = dynamicMappings?.get(pathnamePart);
|
||||
|
||||
if (mappedValue) {
|
||||
if (nonInteractable?.includes(pathnamePart)) {
|
||||
return <Text key={href}>{mappedValue}</Text>;
|
||||
}
|
||||
return (
|
||||
<Anchor key={href} href={href}>
|
||||
{mappedValue}
|
||||
</Anchor>
|
||||
);
|
||||
}
|
||||
|
||||
if (nonInteractable?.includes(pathnamePart)) {
|
||||
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>;
|
||||
}
|
||||
|
||||
if (dynamicMappings?.has(pathnamePart)) {
|
||||
return (
|
||||
<Anchor key={href} href={href}>
|
||||
{dynamicMappings.get(pathnamePart)}
|
||||
</Anchor>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Anchor key={href} href={href}>
|
||||
{t(`${translationKey}.label` as TranslationKeys)}
|
||||
|
||||
@@ -45,7 +45,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
||||
window.location.assign(logoutUrl);
|
||||
return;
|
||||
}
|
||||
router.push("/auth/login");
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
}, [logoutUrl, openModal, router]);
|
||||
|
||||
@@ -102,10 +102,6 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.grid-stack > .grid-stack-item[data-type="item"] {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.grid-stack.grid-stack-animate {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
+10
-17
@@ -249,20 +249,13 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="null" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg" alt="PeaNUT" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">PeaNUT</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/pi-hole" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Pi-hole</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/plex" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg" alt="Plex" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -303,15 +296,15 @@
|
||||
<br/>
|
||||
<p align="center">Radarr</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/readarr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg" alt="Readarr" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Readarr</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -352,15 +345,15 @@
|
||||
<br/>
|
||||
<p align="center">Speedtest<br/>Tracker</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/tdarr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png" alt="Tdarr" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Tdarr</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/tracearr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg" alt="Tracearr" width="90" height="90" />
|
||||
<br/>
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.64.0",
|
||||
"version": "1.61.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -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.17.0",
|
||||
"axios@>=1.0.0 <1.8.2": ">=1.16.1",
|
||||
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
|
||||
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
|
||||
"esbuild@<=0.24.2": ">=0.28.0",
|
||||
@@ -88,13 +88,13 @@
|
||||
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
|
||||
"prismjs@<1.30.0": ">=1.30.0",
|
||||
"proxmox-api>undici": "7.24.6",
|
||||
"react-is": "^19.2.7",
|
||||
"rollup@>=4.0.0 <4.22.4": ">=4.61.1",
|
||||
"react-is": "^19.2.6",
|
||||
"rollup@>=4.0.0 <4.22.4": ">=4.60.4",
|
||||
"sha.js@<=2.4.11": ">=2.4.12",
|
||||
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
|
||||
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
|
||||
"tmp@<=0.2.3": ">=0.2.7",
|
||||
"vite@>=5.0.0 <=5.4.18": ">=8.0.16"
|
||||
"vite@>=5.0.0 <=5.4.18": ">=8.0.14"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@types/node-unifi": "patches/@types__node-unifi.patch",
|
||||
|
||||
@@ -4,11 +4,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
|
||||
import { DockerSingleton } from "@homarr/docker";
|
||||
import {
|
||||
dockerContainersRequestHandler,
|
||||
getContainerLogsAsync,
|
||||
streamContainerLogsAsync,
|
||||
} from "@homarr/request-handler/docker";
|
||||
import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
|
||||
|
||||
import { dockerMiddleware } from "../../middlewares/docker";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||
@@ -108,84 +104,6 @@ export const dockerRouter = createTRPCRouter({
|
||||
const innerHandler = dockerContainersRequestHandler.handler({});
|
||||
await innerHandler.invalidateAsync();
|
||||
}),
|
||||
logs: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
tail: z.number().min(1).max(1000).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const logs = await getContainerLogsAsync(input.id, input.tail ?? 200);
|
||||
|
||||
if (!logs) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Container not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
logs,
|
||||
};
|
||||
}),
|
||||
subscribeLogs: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.concat(dockerMiddleware())
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
tail: z.number().min(1).max(1000).optional(),
|
||||
}),
|
||||
)
|
||||
.subscription(({ input }) => {
|
||||
return observable<string>((emit) => {
|
||||
let cleanupFn: (() => void) | undefined;
|
||||
let isSubscribed = true;
|
||||
|
||||
const initializeStreamAsync = async () => {
|
||||
try {
|
||||
cleanupFn = await streamContainerLogsAsync(
|
||||
input.id,
|
||||
input.tail ?? 200,
|
||||
(data) => {
|
||||
if (isSubscribed) {
|
||||
emit.next(data);
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
if (isSubscribed) {
|
||||
emit.error(
|
||||
new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: err.message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (isSubscribed) {
|
||||
emit.error(
|
||||
new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: err instanceof Error ? err.message : "Container not found",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void initializeStreamAsync();
|
||||
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
cleanupFn?.();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
const getContainerOrDefaultAsync = async (instance: Docker, id: string) => {
|
||||
|
||||
@@ -13,14 +13,6 @@ import { dockerRouter } from "../../docker/docker-router";
|
||||
// Mock the auth module to return an empty session
|
||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
vi.mock("@homarr/request-handler/docker", () => ({
|
||||
getContainerLogsAsync: async () => {
|
||||
await Promise.resolve();
|
||||
return "logs";
|
||||
},
|
||||
streamContainerLogsAsync: async () => {
|
||||
await Promise.resolve();
|
||||
return () => undefined;
|
||||
},
|
||||
dockerContainersRequestHandler: {
|
||||
handler: () => ({
|
||||
getCachedOrUpdatedDataAsync: async () => {
|
||||
@@ -73,8 +65,6 @@ const validInputs: {
|
||||
restartAll: { ids: ["1"] },
|
||||
removeAll: { ids: ["1"] },
|
||||
invalidate: undefined,
|
||||
logs: { id: "1", tail: 200 },
|
||||
subscribeLogs: { id: "1", tail: 200 },
|
||||
};
|
||||
|
||||
describe("All procedures should only be accessible for users with admin permission", () => {
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
import { convertIntersectionToZodObject } from "../schema-merger";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
isDemoMode,
|
||||
onboardingProcedure,
|
||||
permissionRequiredProcedure,
|
||||
protectedProcedure,
|
||||
@@ -630,13 +629,6 @@ export const userRouter = createTRPCRouter({
|
||||
.where(eq(users.id, ctx.session.user.id));
|
||||
}),
|
||||
getTourStatus: protectedProcedure.query(async ({ ctx }) => {
|
||||
if (isDemoMode) {
|
||||
return {
|
||||
completedManageTour: true,
|
||||
completedBoardTour: true,
|
||||
};
|
||||
}
|
||||
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
columns: {
|
||||
completedManageTour: true,
|
||||
|
||||
@@ -41,16 +41,7 @@ import {
|
||||
} from "../schema";
|
||||
import type { Integration } from "../schema";
|
||||
|
||||
const isTruthyEnv = (value: string | undefined) =>
|
||||
["1", "yes", "t", "true"].includes((value ?? "").toLowerCase());
|
||||
|
||||
export const seedDataAsync = async (db: Database) => {
|
||||
if (isTruthyEnv(process.env.UNSAFE_ENABLE_MOCK_INTEGRATION)) {
|
||||
console.warn(
|
||||
"UNSAFE_ENABLE_MOCK_INTEGRATION is enabled: mock integration is available in the UI. Disable by setting UNSAFE_ENABLE_MOCK_INTEGRATION=false.",
|
||||
);
|
||||
}
|
||||
|
||||
await seedEveryoneGroupAsync(db);
|
||||
await seedOnboardingAsync(db);
|
||||
await seedServerSettingsAsync(db);
|
||||
@@ -60,7 +51,7 @@ export const seedDataAsync = async (db: Database) => {
|
||||
await seedDefaultBoardAsync(db);
|
||||
await seedBoardWidgetsAsync(db);
|
||||
|
||||
if (isTruthyEnv(process.env.DEMO_MODE)) {
|
||||
if (["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase())) {
|
||||
await seedDemoUserAsync(db);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -157,7 +157,6 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/anchor"
|
||||
| "/docs/integrations/aria2"
|
||||
| "/docs/integrations/codeberg"
|
||||
| "/docs/integrations/coolify"
|
||||
| "/docs/integrations/dash-dot"
|
||||
| "/docs/integrations/deluge"
|
||||
| "/docs/integrations/docker-hub"
|
||||
@@ -182,7 +181,6 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/open-media-vault"
|
||||
| "/docs/integrations/opnsense"
|
||||
| "/docs/integrations/overseerr"
|
||||
| "/docs/integrations/peanut"
|
||||
| "/docs/integrations/pi-hole"
|
||||
| "/docs/integrations/plex"
|
||||
| "/docs/integrations/prowlarr"
|
||||
@@ -219,7 +217,6 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/bookmarks"
|
||||
| "/docs/widgets/calendar"
|
||||
| "/docs/widgets/clock"
|
||||
| "/docs/widgets/coolify"
|
||||
| "/docs/widgets/dns-hole-controls"
|
||||
| "/docs/widgets/dns-hole-summary"
|
||||
| "/docs/widgets/docker-containers"
|
||||
@@ -250,7 +247,6 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/timetable"
|
||||
| "/docs/widgets/tracearr"
|
||||
| "/docs/widgets/umami"
|
||||
| "/docs/widgets/ups"
|
||||
| "/docs/widgets/video"
|
||||
| "/docs/widgets/weather"
|
||||
| ""
|
||||
|
||||
@@ -364,7 +364,7 @@ export const integrationDefs = {
|
||||
},
|
||||
truenas: {
|
||||
name: "TrueNAS",
|
||||
secretKinds: [["username", "password"], ["apiKey"]],
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
|
||||
@@ -383,6 +383,7 @@ export const integrationDefs = {
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg",
|
||||
category: ["healthMonitoring"],
|
||||
// @ts-expect-error - docs page will be created when integration is merged
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/coolify"),
|
||||
defaultPort: 8000,
|
||||
},
|
||||
|
||||
@@ -11,23 +11,3 @@ export const containerStateColorMap = {
|
||||
removing: "pink",
|
||||
dead: "dark",
|
||||
} satisfies Record<ContainerState, MantineColor>;
|
||||
|
||||
export const memoryUsageColor = (number: number, state: string): MantineColor => {
|
||||
const mbUsage = number / 1024 / 1024;
|
||||
if (mbUsage === 0 && state !== "running") return "red";
|
||||
if (mbUsage < 128) return "green";
|
||||
if (mbUsage < 256) return "yellow";
|
||||
if (mbUsage < 512) return "orange";
|
||||
return "red";
|
||||
};
|
||||
|
||||
export const cpuUsageColor = (number: number, state: string): MantineColor => {
|
||||
if (number === 0 && state !== "running") return "red";
|
||||
if (number < 40) return "green";
|
||||
if (number < 60) return "yellow";
|
||||
if (number < 90) return "orange";
|
||||
return "red";
|
||||
};
|
||||
|
||||
export const safeValue = (value?: number, fallback = 0): number =>
|
||||
value !== undefined && !isNaN(value) ? value : fallback;
|
||||
@@ -47,14 +47,12 @@
|
||||
"timezones-ical-library": "catalog:",
|
||||
"tsdav": "catalog:",
|
||||
"undici": "catalog:",
|
||||
"ws": "catalog:",
|
||||
"xml2js": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
"@types/node-unifi": "catalog:",
|
||||
"@types/ws": "catalog:",
|
||||
"@types/xml2js": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
|
||||
@@ -85,16 +85,6 @@ 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,79 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,264 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
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>;
|
||||
@@ -1,315 +0,0 @@
|
||||
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,6 +1,8 @@
|
||||
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";
|
||||
@@ -8,21 +10,252 @@ 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 client?: TrueNasClient;
|
||||
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);
|
||||
|
||||
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();
|
||||
@@ -39,7 +272,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, index) => index > 0)),
|
||||
cpuTemp: Math.max(...cpuTempData.filter((_item, i) => i > 0)),
|
||||
memAvailableInBytes: systemInformation.physmem,
|
||||
memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes
|
||||
fileSystem: datasets.map((dataset) => ({
|
||||
@@ -68,164 +301,87 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
||||
};
|
||||
}
|
||||
|
||||
private getClient(): TrueNasClient {
|
||||
return (this.client ??= this.createClient());
|
||||
}
|
||||
|
||||
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
|
||||
* 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 getReportingAsync(): Promise<ReportingItem[]> {
|
||||
logger.debug("Retrieving reporting data", { url: this.integration.url });
|
||||
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);
|
||||
|
||||
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);
|
||||
TrueNasIntegration.webSocketMap.set(this.integration.id, webSocket);
|
||||
await this.authenticateWebSocketAsync();
|
||||
}
|
||||
|
||||
logger.debug("Retrieved reporting data", {
|
||||
url: this.integration.url,
|
||||
count: result.length,
|
||||
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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
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) => {
|
||||
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[], 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;
|
||||
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]!;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createDockerLogStreamProcessor, decodeDockerLogs } from "./docker-log-decode";
|
||||
|
||||
const encodeDockerLogFrame = (streamType: number, message: string) => {
|
||||
const payload = Buffer.from(message, "utf-8");
|
||||
const header = Buffer.alloc(8);
|
||||
header.writeUInt8(streamType, 0);
|
||||
header.writeUInt32BE(payload.length, 4);
|
||||
return Buffer.concat([header, payload]);
|
||||
};
|
||||
|
||||
describe("decodeDockerLogs", () => {
|
||||
test("decodes multiplexed docker log frames", () => {
|
||||
const logs = Buffer.concat([encodeDockerLogFrame(1, "hello "), encodeDockerLogFrame(2, "world")]);
|
||||
|
||||
expect(decodeDockerLogs(logs)).toBe("hello world");
|
||||
});
|
||||
|
||||
test("returns plain text logs unchanged", () => {
|
||||
expect(decodeDockerLogs("plain text log")).toBe("plain text log");
|
||||
});
|
||||
|
||||
test("falls back to raw buffer text for non-multiplexed output", () => {
|
||||
expect(decodeDockerLogs(Buffer.from("raw docker output", "utf-8"))).toBe("raw docker output");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDockerLogStreamProcessor", () => {
|
||||
test("emits decoded chunks from a stream", () => {
|
||||
const messages: string[] = [];
|
||||
const processChunk = createDockerLogStreamProcessor(
|
||||
(data) => messages.push(data),
|
||||
() => undefined,
|
||||
1024,
|
||||
);
|
||||
|
||||
const chunk = encodeDockerLogFrame(1, "streamed log");
|
||||
expect(processChunk(chunk)).toBe(true);
|
||||
expect(messages).toEqual(["streamed log"]);
|
||||
});
|
||||
|
||||
test("returns false when message size exceeds limit", () => {
|
||||
const processChunk = createDockerLogStreamProcessor(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
4,
|
||||
);
|
||||
|
||||
const chunk = encodeDockerLogFrame(1, "this message is too long");
|
||||
expect(processChunk(chunk)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
const DOCKER_STREAM_HEADER_SIZE = 8;
|
||||
const DOCKER_STREAM_TYPES = new Set([1, 2]);
|
||||
|
||||
export const decodeDockerLogs = (logs: Buffer | string) => {
|
||||
if (typeof logs === "string") {
|
||||
return logs;
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let cursor = 0;
|
||||
const parts: string[] = [];
|
||||
|
||||
while (cursor < logs.length) {
|
||||
if (cursor + DOCKER_STREAM_HEADER_SIZE > logs.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const streamType = logs.readUInt8(cursor);
|
||||
if (!DOCKER_STREAM_TYPES.has(streamType)) {
|
||||
return logs.toString("utf-8");
|
||||
}
|
||||
|
||||
const length = logs.readUInt32BE(cursor + 4);
|
||||
const start = cursor + DOCKER_STREAM_HEADER_SIZE;
|
||||
const end = start + length;
|
||||
if (end > logs.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
parts.push(logs.subarray(start, end).toString("utf-8"));
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
const decoded = parts.join("");
|
||||
if (decoded.length === 0) {
|
||||
return logs.toString("utf-8");
|
||||
}
|
||||
|
||||
return decoded;
|
||||
};
|
||||
|
||||
export const createDockerLogStreamProcessor = (
|
||||
onData: (data: string) => void,
|
||||
onError: (err: Error) => void,
|
||||
maxMessageSize: number,
|
||||
) => {
|
||||
let buffer = Buffer.alloc(0);
|
||||
|
||||
const processChunk = (chunk: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
|
||||
while (buffer.length > 0) {
|
||||
if (buffer.length < DOCKER_STREAM_HEADER_SIZE) {
|
||||
break;
|
||||
}
|
||||
|
||||
const streamType = buffer.readUInt8(0);
|
||||
if (!DOCKER_STREAM_TYPES.has(streamType)) {
|
||||
onData(buffer.toString("utf-8"));
|
||||
buffer = Buffer.alloc(0);
|
||||
break;
|
||||
}
|
||||
|
||||
const length = buffer.readUInt32BE(4);
|
||||
if (length > maxMessageSize) {
|
||||
onError(
|
||||
new Error(`Docker log message size (${length} bytes) exceeds maximum allowed (${maxMessageSize} bytes)`),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const fullMessageSize = DOCKER_STREAM_HEADER_SIZE + length;
|
||||
if (buffer.length < fullMessageSize) {
|
||||
break;
|
||||
}
|
||||
|
||||
const payload = buffer.subarray(DOCKER_STREAM_HEADER_SIZE, fullMessageSize);
|
||||
onData(payload.toString("utf-8"));
|
||||
buffer = buffer.subarray(fullMessageSize);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
return processChunk;
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Readable } from "node:stream";
|
||||
import dayjs from "dayjs";
|
||||
import type { Container, ContainerInfo, ContainerStats } from "dockerode";
|
||||
import type { ContainerInfo, ContainerStats } from "dockerode";
|
||||
import type Dockerode from "dockerode";
|
||||
|
||||
import { bestMatch } from "@homarr/common";
|
||||
@@ -12,7 +11,6 @@ import { extractContainerImageName } from "@homarr/definitions";
|
||||
import type { ContainerState, Port } from "@homarr/docker";
|
||||
import { dockerLabels, DockerSingleton } from "@homarr/docker";
|
||||
|
||||
import { createDockerLogStreamProcessor, decodeDockerLogs } from "./docker-log-decode";
|
||||
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
|
||||
|
||||
const logger = createLogger({ module: "dockerRequestHandler" });
|
||||
@@ -185,84 +183,6 @@ export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
|
||||
|
||||
const extractImage = (container: ContainerInfo) => extractContainerImageName(container.Image);
|
||||
|
||||
const findContainerByIdAsync = async (id: string) => {
|
||||
const dockerInstances = DockerSingleton.getInstances();
|
||||
const containers = await Promise.all(
|
||||
dockerInstances.map(async ({ instance }) => {
|
||||
const container = instance.getContainer(id);
|
||||
|
||||
return await new Promise<Container | null>((resolve) => {
|
||||
container.inspect((err, data) => {
|
||||
if (err || !data) {
|
||||
resolve(null);
|
||||
} else {
|
||||
resolve(container);
|
||||
}
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return containers.find((container) => container) ?? null;
|
||||
};
|
||||
|
||||
export const getContainerLogsAsync = async (id: string, tail = 200) => {
|
||||
const container = await findContainerByIdAsync(id);
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawLogs = await container.logs({
|
||||
tail,
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
follow: false,
|
||||
});
|
||||
|
||||
return decodeDockerLogs(rawLogs);
|
||||
};
|
||||
|
||||
export const streamContainerLogsAsync = async (
|
||||
id: string,
|
||||
tail: number,
|
||||
onData: (data: string) => void,
|
||||
onError: (err: Error) => void,
|
||||
) => {
|
||||
const container = await findContainerByIdAsync(id);
|
||||
if (!container) {
|
||||
onError(new Error("Container not found"));
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const stream = (await container.logs({
|
||||
tail,
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
follow: true,
|
||||
})) as Readable;
|
||||
|
||||
const MAX_MESSAGE_SIZE = 1024 * 1024;
|
||||
const processChunk = createDockerLogStreamProcessor(onData, onError, MAX_MESSAGE_SIZE);
|
||||
|
||||
const handleChunk = (chunk: Buffer) => {
|
||||
const shouldContinue = processChunk(chunk);
|
||||
if (!shouldContinue) {
|
||||
stream.removeListener("data", handleChunk);
|
||||
stream.removeListener("error", onError);
|
||||
stream.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
stream.on("data", handleChunk);
|
||||
stream.on("error", onError);
|
||||
|
||||
return () => {
|
||||
stream.removeListener("data", handleChunk);
|
||||
stream.removeListener("error", onError);
|
||||
stream.destroy();
|
||||
};
|
||||
};
|
||||
|
||||
async function getContainersWithStatsAsync() {
|
||||
const dockerInstances = DockerSingleton.getInstances();
|
||||
const results = await Promise.allSettled(
|
||||
@@ -355,6 +275,8 @@ export function calculateMemoryUsage(stats: ContainerStats): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// memory usage by default includes cache, which should not be shown as it is also not shown with docker stats command
|
||||
// See https://docs.docker.com/reference/cli/docker/container/stats/ how it is / was calculated
|
||||
return (
|
||||
stats.memory_stats.usage -
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
|
||||
@@ -4123,12 +4123,6 @@
|
||||
},
|
||||
"action": {
|
||||
"title": "Actions",
|
||||
"logs": {
|
||||
"label": "View logs",
|
||||
"page": {
|
||||
"title": "{name} logs"
|
||||
}
|
||||
},
|
||||
"start": {
|
||||
"label": "Start",
|
||||
"notification": {
|
||||
@@ -4541,10 +4535,7 @@
|
||||
"label": "Tasks"
|
||||
},
|
||||
"docker": {
|
||||
"label": "Docker",
|
||||
"logs": {
|
||||
"label": "Logs"
|
||||
}
|
||||
"label": "Docker"
|
||||
},
|
||||
"kubernetes": {
|
||||
"label": "Kubernetes",
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { humanFileSize, useTimeAgo } from "@homarr/common";
|
||||
import type { ContainerState } from "@homarr/docker";
|
||||
import { containerStateColorMap, cpuUsageColor, memoryUsageColor, safeValue } from "@homarr/docker/shared";
|
||||
import { containerStateColorMap } from "@homarr/docker/shared";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
|
||||
@@ -28,6 +28,25 @@ const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const memoryUsageColor = (number: number, state: string) => {
|
||||
const mbUsage = number / 1024 / 1024;
|
||||
if (mbUsage === 0 && state !== "running") return "red";
|
||||
if (mbUsage < 128) return "green";
|
||||
if (mbUsage < 256) return "yellow";
|
||||
if (mbUsage < 512) return "orange";
|
||||
return "red";
|
||||
};
|
||||
|
||||
const cpuUsageColor = (number: number, state: string) => {
|
||||
if (number === 0 && state !== "running") return "red";
|
||||
if (number < 40) return "green";
|
||||
if (number < 60) return "yellow";
|
||||
if (number < 90) return "orange";
|
||||
return "red";
|
||||
};
|
||||
|
||||
const safeValue = (value?: number, fallback = 0) => (value !== undefined && !isNaN(value) ? value : fallback);
|
||||
|
||||
const actionIconIconStyle: IconProps["style"] = {
|
||||
height: "var(--ai-icon-size)",
|
||||
width: "var(--ai-icon-size)",
|
||||
|
||||
@@ -53,7 +53,6 @@ import * as weather from "./weather";
|
||||
|
||||
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
|
||||
export type { WidgetComponentProps };
|
||||
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
|
||||
|
||||
export const widgetImports = {
|
||||
clock,
|
||||
|
||||
Generated
+1019
-1029
File diff suppressed because it is too large.
Load diff
+42
-42
@@ -14,25 +14,25 @@ catalog:
|
||||
"@dnd-kit/utilities": ^3.2.2
|
||||
"@drizzle-team/brocli": ^0.12.0
|
||||
"@extractus/feed-extractor": 7.2.1
|
||||
'@gfazioli/mantine-onboarding-tour': ^4.0.4
|
||||
'@gfazioli/mantine-onboarding-tour': ^4.0.1
|
||||
"@gitbeaker/rest": ^43.8.0
|
||||
"@homarr/gridstack": ^1.12.0
|
||||
"@homarr/node-unifi": ^2.6.0
|
||||
"@immich/sdk": ^2.7.5
|
||||
"@jellyfin/sdk": ^0.13.0
|
||||
"@kubernetes/client-node": ^1.4.0
|
||||
"@mantine/charts": ^9.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
|
||||
"@mantine/charts": ^9.2.2
|
||||
"@mantine/colors-generator": ^9.2.2
|
||||
"@mantine/core": ^9.2.2
|
||||
"@mantine/dates": ^9.2.2
|
||||
"@mantine/dropzone": ^9.2.2
|
||||
"@mantine/form": ^9.2.2
|
||||
"@mantine/hooks": ^9.2.2
|
||||
"@mantine/modals": ^9.2.2
|
||||
"@mantine/notifications": ^9.2.2
|
||||
"@mantine/spotlight": ^9.2.2
|
||||
"@mantine/tiptap": ^9.2.2
|
||||
"@next/eslint-plugin-next": 16.2.6
|
||||
"@octokit/auth-app": ^8.2.0
|
||||
"@paralleldrive/cuid2": ^3.3.0
|
||||
"@playwright/test": ^1.60.0
|
||||
@@ -44,28 +44,28 @@ catalog:
|
||||
"@semantic-release/release-notes-generator": ^14.1.1
|
||||
"@t3-oss/env-nextjs": ^0.13.11
|
||||
"@tabler/icons-react": ^3.44.0
|
||||
"@tanstack/react-query": ^5.101.0
|
||||
"@tanstack/react-query-devtools": ^5.101.0
|
||||
"@tanstack/react-query-next-experimental": ^5.101.0
|
||||
"@tanstack/react-query": ^5.100.14
|
||||
"@tanstack/react-query-devtools": ^5.100.14
|
||||
"@tanstack/react-query-next-experimental": ^5.100.14
|
||||
"@testcontainers/mysql": ^12.0.1
|
||||
"@testcontainers/postgresql": ^12.0.1
|
||||
"@testcontainers/redis": ^12.0.1
|
||||
"@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
|
||||
"@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
|
||||
"@trpc/client": ^11.17.0
|
||||
"@trpc/next": ^11.17.0
|
||||
"@trpc/react-query": ^11.17.0
|
||||
@@ -84,7 +84,7 @@ catalog:
|
||||
"@types/node-unifi": ^2.5.1
|
||||
"@types/pg": ^8.20.0
|
||||
"@types/prismjs": ^1.26.6
|
||||
"@types/react": 19.2.16
|
||||
"@types/react": 19.2.15
|
||||
"@types/react-dom": 19.2.3
|
||||
"@types/swagger-ui-react": ^5.18.0
|
||||
"@types/video.js": ^7.3.58
|
||||
@@ -92,8 +92,8 @@ catalog:
|
||||
"@types/xml2js": ^0.4.14
|
||||
"@umami/node": ^0.4.0
|
||||
"@vitejs/plugin-react": ^5.2.0
|
||||
"@vitest/coverage-v8": ^4.1.8
|
||||
"@vitest/ui": ^4.1.8
|
||||
"@vitest/coverage-v8": ^4.1.7
|
||||
"@vitest/ui": ^4.1.7
|
||||
"@xterm/addon-canvas": ^0.7.0
|
||||
"@xterm/addon-fit": 0.11.0
|
||||
"@xterm/xterm": ^6.0.0
|
||||
@@ -126,7 +126,7 @@ catalog:
|
||||
glob: ^13.0.6
|
||||
ical.js: ^2.2.1
|
||||
ioredis: 5.11.0
|
||||
isomorphic-dompurify: ^3.16.0
|
||||
isomorphic-dompurify: ^3.15.0
|
||||
jotai: ^2.20.0
|
||||
jsdom: ^29.1.1
|
||||
json5: ^2.2.3
|
||||
@@ -135,19 +135,19 @@ catalog:
|
||||
mantine-react-table: npm:mantine-react-table-open@9.0.3
|
||||
maria2: ^0.4.1
|
||||
mysql2: 3.22.4
|
||||
next: 16.2.7
|
||||
next: 16.2.6
|
||||
next-auth: 5.0.0-beta.31
|
||||
next-intl: 4.13.0
|
||||
node-loader: ^2.1.0
|
||||
octokit: ^5.0.5
|
||||
oxfmt: ^0.53.0
|
||||
oxfmt: ^0.52.0
|
||||
oxlint: ^1.67.0
|
||||
pg: ^8.21.0
|
||||
postcss-preset-mantine: ^1.18.0
|
||||
prismjs: ^1.30.0
|
||||
proxmox-api: 1.1.1
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6
|
||||
react-error-boundary: ^6.1.2
|
||||
react-markdown: ^10.1.0
|
||||
react-quill-new: ^3.8.3
|
||||
@@ -155,7 +155,7 @@ catalog:
|
||||
recharts: ^3.8.1
|
||||
sass: ^1.100.0
|
||||
semantic-release: ^25.0.3
|
||||
semver-parser: ^4.2.1
|
||||
semver-parser: ^4.2.0
|
||||
superjson: 2.2.6
|
||||
svgson: ^5.3.1
|
||||
swagger-ui-react: ^5.32.6
|
||||
@@ -170,7 +170,7 @@ catalog:
|
||||
use-deep-compare-effect: ^1.8.1
|
||||
video.js: ^8.23.7
|
||||
vite-tsconfig-paths: ^6.1.1
|
||||
vitest: ^4.1.8
|
||||
vitest: ^4.1.7
|
||||
winston: 3.19.0
|
||||
ws: ^8.21.0
|
||||
xml2js: ^0.6.2
|
||||
|
||||
@@ -1 +1 @@
|
||||
[{"login":"ajnart","avatar_url":"https://avatars.githubusercontent.com/u/49837342?v=4"},{"login":"manuel-rw","avatar_url":"https://avatars.githubusercontent.com/u/30572287?v=4"},{"login":"Meierschlumpf","avatar_url":"https://avatars.githubusercontent.com/u/63781622?v=4"},{"login":"SeDemal","avatar_url":"https://avatars.githubusercontent.com/u/26098587?v=4"},{"login":"lammersbjorn","avatar_url":"https://avatars.githubusercontent.com/u/71191962?v=4"},{"login":"LarveyOfficial","avatar_url":"https://avatars.githubusercontent.com/u/39219859?v=4"},{"login":"c00ldude1oo","avatar_url":"https://avatars.githubusercontent.com/u/15677803?v=4"},{"login":"InterN0te","avatar_url":"https://avatars.githubusercontent.com/u/10882916?v=4"},{"login":"Aimsucks","avatar_url":"https://avatars.githubusercontent.com/u/1828486?v=4"},{"login":"JannesV","avatar_url":"https://avatars.githubusercontent.com/u/5984244?v=4"},{"login":"MauriceNino","avatar_url":"https://avatars.githubusercontent.com/u/18658092?v=4"},{"login":"hillaliy","avatar_url":"https://avatars.githubusercontent.com/u/950010?v=4"},{"login":"Skeeww","avatar_url":"https://avatars.githubusercontent.com/u/9639992?v=4"},{"login":"RichyHBM","avatar_url":"https://avatars.githubusercontent.com/u/5472275?v=4"},{"login":"Bonfire","avatar_url":"https://avatars.githubusercontent.com/u/5704760?v=4"},{"login":"wieli99","avatar_url":"https://avatars.githubusercontent.com/u/46794825?v=4"},{"login":"ishaanparlikar","avatar_url":"https://avatars.githubusercontent.com/u/43013132?v=4"},{"login":"Zareix","avatar_url":"https://avatars.githubusercontent.com/u/29818713?v=4"},{"login":"tuggan","avatar_url":"https://avatars.githubusercontent.com/u/4325608?v=4"},{"login":"Spillebulle","avatar_url":"https://avatars.githubusercontent.com/u/46653946?v=4"},{"login":"marektancak0106","avatar_url":"https://avatars.githubusercontent.com/u/25422667?v=4"},{"login":"spkesDE","avatar_url":"https://avatars.githubusercontent.com/u/1682314?v=4"},{"login":"j3lte","avatar_url":"https://avatars.githubusercontent.com/u/2557568?v=4"},{"login":"dslatt","avatar_url":"https://avatars.githubusercontent.com/u/5211001?v=4"},{"login":"Malong11-007","avatar_url":"https://avatars.githubusercontent.com/u/40298510?v=4"},{"login":"StefanB7","avatar_url":"https://avatars.githubusercontent.com/u/22056509?v=4"},{"login":"souravghosh01","avatar_url":"https://avatars.githubusercontent.com/u/37548139?v=4"},{"login":"SR3u","avatar_url":"https://avatars.githubusercontent.com/u/2380522?v=4"},{"login":"Rikpat","avatar_url":"https://avatars.githubusercontent.com/u/33869814?v=4"},{"login":"NormanJS","avatar_url":"https://avatars.githubusercontent.com/u/83409549?v=4"},{"login":"No-Maines-Land","avatar_url":"https://avatars.githubusercontent.com/u/20687725?v=4"},{"login":"stark1tty","avatar_url":"https://avatars.githubusercontent.com/u/69326376?v=4"},{"login":"Momcilo42","avatar_url":"https://avatars.githubusercontent.com/u/10118137?v=4"},{"login":"marius-arch","avatar_url":"https://avatars.githubusercontent.com/u/72494359?v=4"},{"login":"Lumilias","avatar_url":"https://avatars.githubusercontent.com/u/10852161?v=4"},{"login":"Havock94","avatar_url":"https://avatars.githubusercontent.com/u/7635248?v=4"},{"login":"Lollyde","avatar_url":"https://avatars.githubusercontent.com/u/11347524?v=4"},{"login":"krishnamuppaneni","avatar_url":"https://avatars.githubusercontent.com/u/4703366?v=4"},{"login":"devtobi","avatar_url":"https://avatars.githubusercontent.com/u/28538704?v=4"},{"login":"TyxTang","avatar_url":"https://avatars.githubusercontent.com/u/90897955?v=4"},{"login":"alotlikebeans","avatar_url":"https://avatars.githubusercontent.com/u/19630511?v=4"},{"login":"WillyJL","avatar_url":"https://avatars.githubusercontent.com/u/49810075?v=4"},{"login":"anonysoul","avatar_url":"https://avatars.githubusercontent.com/u/105715150?v=4"},{"login":"cospeedster","avatar_url":"https://avatars.githubusercontent.com/u/52749645?v=4"},{"login":"diederbert","avatar_url":"https://avatars.githubusercontent.com/u/162878798?v=4"},{"login":"digdilem","avatar_url":"https://avatars.githubusercontent.com/u/4052275?v=4"},{"login":"dinghino","avatar_url":"https://avatars.githubusercontent.com/u/14729459?v=4"},{"login":"gfomichev","avatar_url":"https://avatars.githubusercontent.com/u/1596146?v=4"},{"login":"gnattu","avatar_url":"https://avatars.githubusercontent.com/u/12995396?v=4"},{"login":"tuxpizza","avatar_url":"https://avatars.githubusercontent.com/u/84710786?v=4"},{"login":"xmarkurion","avatar_url":"https://avatars.githubusercontent.com/u/46654687?v=4"},{"login":"huanga","avatar_url":"https://avatars.githubusercontent.com/u/732970?v=4"},{"login":"taos15","avatar_url":"https://avatars.githubusercontent.com/u/122130728?v=4"},{"login":"arghyadipchak","avatar_url":"https://avatars.githubusercontent.com/u/32430374?v=4"},{"login":"AuthorShin","avatar_url":"https://avatars.githubusercontent.com/u/4959043?v=4"},{"login":"benniblot","avatar_url":"https://avatars.githubusercontLine truncated
|
||||
[{"login":"ajnart","avatar_url":"https://avatars.githubusercontent.com/u/49837342?v=4"},{"login":"manuel-rw","avatar_url":"https://avatars.githubusercontent.com/u/30572287?v=4"},{"login":"Meierschlumpf","avatar_url":"https://avatars.githubusercontent.com/u/63781622?v=4"},{"login":"SeDemal","avatar_url":"https://avatars.githubusercontent.com/u/26098587?v=4"},{"login":"lammersbjorn","avatar_url":"https://avatars.githubusercontent.com/u/71191962?v=4"},{"login":"LarveyOfficial","avatar_url":"https://avatars.githubusercontent.com/u/39219859?v=4"},{"login":"c00ldude1oo","avatar_url":"https://avatars.githubusercontent.com/u/15677803?v=4"},{"login":"InterN0te","avatar_url":"https://avatars.githubusercontent.com/u/10882916?v=4"},{"login":"Aimsucks","avatar_url":"https://avatars.githubusercontent.com/u/1828486?v=4"},{"login":"JannesV","avatar_url":"https://avatars.githubusercontent.com/u/5984244?v=4"},{"login":"MauriceNino","avatar_url":"https://avatars.githubusercontent.com/u/18658092?v=4"},{"login":"hillaliy","avatar_url":"https://avatars.githubusercontent.com/u/950010?v=4"},{"login":"Skeeww","avatar_url":"https://avatars.githubusercontent.com/u/9639992?v=4"},{"login":"RichyHBM","avatar_url":"https://avatars.githubusercontent.com/u/5472275?v=4"},{"login":"Bonfire","avatar_url":"https://avatars.githubusercontent.com/u/5704760?v=4"},{"login":"wieli99","avatar_url":"https://avatars.githubusercontent.com/u/46794825?v=4"},{"login":"ishaanparlikar","avatar_url":"https://avatars.githubusercontent.com/u/43013132?v=4"},{"login":"Zareix","avatar_url":"https://avatars.githubusercontent.com/u/29818713?v=4"},{"login":"tuggan","avatar_url":"https://avatars.githubusercontent.com/u/4325608?v=4"},{"login":"Spillebulle","avatar_url":"https://avatars.githubusercontent.com/u/46653946?v=4"},{"login":"marektancak0106","avatar_url":"https://avatars.githubusercontent.com/u/25422667?v=4"},{"login":"spkesDE","avatar_url":"https://avatars.githubusercontent.com/u/1682314?v=4"},{"login":"j3lte","avatar_url":"https://avatars.githubusercontent.com/u/2557568?v=4"},{"login":"dslatt","avatar_url":"https://avatars.githubusercontent.com/u/5211001?v=4"},{"login":"Malong11-007","avatar_url":"https://avatars.githubusercontent.com/u/40298510?v=4"},{"login":"StefanB7","avatar_url":"https://avatars.githubusercontent.com/u/22056509?v=4"},{"login":"souravghosh01","avatar_url":"https://avatars.githubusercontent.com/u/37548139?v=4"},{"login":"SR3u","avatar_url":"https://avatars.githubusercontent.com/u/2380522?v=4"},{"login":"Rikpat","avatar_url":"https://avatars.githubusercontent.com/u/33869814?v=4"},{"login":"NormanJS","avatar_url":"https://avatars.githubusercontent.com/u/83409549?v=4"},{"login":"No-Maines-Land","avatar_url":"https://avatars.githubusercontent.com/u/20687725?v=4"},{"login":"stark1tty","avatar_url":"https://avatars.githubusercontent.com/u/69326376?v=4"},{"login":"Momcilo42","avatar_url":"https://avatars.githubusercontent.com/u/10118137?v=4"},{"login":"marius-arch","avatar_url":"https://avatars.githubusercontent.com/u/72494359?v=4"},{"login":"Lumilias","avatar_url":"https://avatars.githubusercontent.com/u/10852161?v=4"},{"login":"Havock94","avatar_url":"https://avatars.githubusercontent.com/u/7635248?v=4"},{"login":"Lollyde","avatar_url":"https://avatars.githubusercontent.com/u/11347524?v=4"},{"login":"krishnamuppaneni","avatar_url":"https://avatars.githubusercontent.com/u/4703366?v=4"},{"login":"devtobi","avatar_url":"https://avatars.githubusercontent.com/u/28538704?v=4"},{"login":"TyxTang","avatar_url":"https://avatars.githubusercontent.com/u/90897955?v=4"},{"login":"alotlikebeans","avatar_url":"https://avatars.githubusercontent.com/u/19630511?v=4"},{"login":"WillyJL","avatar_url":"https://avatars.githubusercontent.com/u/49810075?v=4"},{"login":"anonysoul","avatar_url":"https://avatars.githubusercontent.com/u/105715150?v=4"},{"login":"cospeedster","avatar_url":"https://avatars.githubusercontent.com/u/52749645?v=4"},{"login":"diederbert","avatar_url":"https://avatars.githubusercontent.com/u/162878798?v=4"},{"login":"digdilem","avatar_url":"https://avatars.githubusercontent.com/u/4052275?v=4"},{"login":"dinghino","avatar_url":"https://avatars.githubusercontent.com/u/14729459?v=4"},{"login":"gfomichev","avatar_url":"https://avatars.githubusercontent.com/u/1596146?v=4"},{"login":"gnattu","avatar_url":"https://avatars.githubusercontent.com/u/12995396?v=4"},{"login":"tuxpizza","avatar_url":"https://avatars.githubusercontent.com/u/84710786?v=4"},{"login":"xmarkurion","avatar_url":"https://avatars.githubusercontent.com/u/46654687?v=4"},{"login":"huanga","avatar_url":"https://avatars.githubusercontent.com/u/732970?v=4"},{"login":"taos15","avatar_url":"https://avatars.githubusercontent.com/u/122130728?v=4"},{"login":"arghyadipchak","avatar_url":"https://avatars.githubusercontent.com/u/32430374?v=4"},{"login":"AuthorShin","avatar_url":"https://avatars.githubusercontent.com/u/4959043?v=4"},{"login":"benniblot","avatar_url":"https://avatars.githubusercontLine truncated
|
||||
@@ -1 +1 @@
|
||||
[{"username":"carlchina","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/6697/medium/d22bbe7797bbeb30dbdc73a5648d329a_default.png"},{"username":"zielmann","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/7795/medium/ad22b8b8d5eb33e4154d53a454c862fd_default.png"},{"username":"Thalyn","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12494028/medium/5faf5acc945a874ed116c4567edaa09f_default.png"},{"username":"magnushj","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12503488/medium/68f32e33ea7b891960055c9f831f7344_default.png"},{"username":"almontegil","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12580457/medium/f4136cacbdfdb4c28ae7f85dc5f840db_default.png"},{"username":"andibing","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12664938/medium/b8be63e4dcb2e791ced1ffc9e3a049a5.jpg"},{"username":"gronare","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12680911/medium/c6eb14d1504156f34a158e507f73aa3d_default.png"},{"username":"SmartPhoneLover","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12701640/medium/ec95486662ec875cda080e778c3ff702.jpg"},{"username":"marcelotk_15","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12773227/medium/ecf6bea16ab0c873c131df2154ee9718_default.png"},{"username":"S3OD177","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13051544/medium/11dd990837f61f7e8f7d36b041a7fddb_default.png"},{"username":"marcg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13153301/medium/ebcf60fbf27deb148ec40a659566c6ff_default.png"},{"username":"BeersTeddy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13185230/medium/2f1f4e1effe74a23422b195cbefb2a95_default.png"},{"username":"jamesmcmahon0","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13245578/medium/586aa873b4abddbd9abc6f3de99ab70e.jpeg"},{"username":"b1nar10","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13286752/medium/249b02e3368752cb175446082015737c_default.png"},{"username":"vannCN","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13330448/medium/9b8a9ee3611e51e951e22d5fd4eb7d8d.jpg"},{"username":"binswm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13343482/medium/45dde0e6097b9b72705d2eba9dbbc276_default.png"},{"username":"SkewRam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13496556/medium/188f5c2deb7938eda51eb786cc4539ca.jpeg"},{"username":"raphcatarino","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13547726/medium/b003511e67df13a4b4b5689488fa8099.jpg"},{"username":"shillos5","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13600557/medium/b17650b2b84f829d54fa548c44c1ab27.jpeg"},{"username":"wolong98","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13641407/medium/f4634edc58c7857a357e5293543c15cf.jpg"},{"username":"spair0039","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14012333/medium/72430e96027c09c19141cac38eae4617.png"},{"username":"MajorMarcin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14072183/medium/0a197e64bb92b6ea94cecbb607512c45.jpg"},{"username":"KosmoMoustache","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14103535/medium/51c438cdd9f86e8811c75f4869de479c.png"},{"username":"Soochaehwa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14118689/medium/496a1ce63111547bf455a1e0a7ac75f1_default.png"},{"username":"garryfield","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14187035/medium/520c964706676045f6eeec1b08edc880.jpg"},{"username":"droidenko","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14444264/medium/4aa3a8e824f72bc9e5ec0d8de307095e.jpeg"},{"username":"maathis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14489950/medium/6eeddf13a757ee35f78f1763f94d95e1.jpeg"},{"username":"bukvam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14546502/medium/a819dc40a13c57395a17dc46e847d3ce.jpg"},{"username":"gm.cinalli","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14670666/medium/2d466a0fdbda40764526be86c97c0ab4.jpeg"},{"username":"antoine2tt","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14722148/medium/f88d926900862dd59007ea4b3419cb9d.png"},{"username":"cretzen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14799754/medium/3ba5c1805ec20c000d5810c07985afe9.jpg"},{"username":"ktKongTong","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14817246/medium/97cfc3c028dbdaf85ebd1102da71e58c.jpeg"},{"username":"marinkaberg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14861042/medium/2d5c4e62613f03082f3e645fa92efd59.jpeg"},{"username":"djismgaming","avatarUrl":"httpsLine truncated
|
||||
[{"username":"carlchina","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/6697/medium/d22bbe7797bbeb30dbdc73a5648d329a_default.png"},{"username":"zielmann","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/7795/medium/ad22b8b8d5eb33e4154d53a454c862fd_default.png"},{"username":"Thalyn","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12494028/medium/5faf5acc945a874ed116c4567edaa09f_default.png"},{"username":"magnushj","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12503488/medium/68f32e33ea7b891960055c9f831f7344_default.png"},{"username":"almontegil","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12580457/medium/f4136cacbdfdb4c28ae7f85dc5f840db_default.png"},{"username":"andibing","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12664938/medium/b8be63e4dcb2e791ced1ffc9e3a049a5.jpg"},{"username":"gronare","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12680911/medium/c6eb14d1504156f34a158e507f73aa3d_default.png"},{"username":"SmartPhoneLover","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12701640/medium/ec95486662ec875cda080e778c3ff702.jpg"},{"username":"marcelotk_15","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/12773227/medium/ecf6bea16ab0c873c131df2154ee9718_default.png"},{"username":"S3OD177","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13051544/medium/11dd990837f61f7e8f7d36b041a7fddb_default.png"},{"username":"marcg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13153301/medium/ebcf60fbf27deb148ec40a659566c6ff_default.png"},{"username":"BeersTeddy","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13185230/medium/2f1f4e1effe74a23422b195cbefb2a95_default.png"},{"username":"jamesmcmahon0","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13245578/medium/586aa873b4abddbd9abc6f3de99ab70e.jpeg"},{"username":"b1nar10","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13286752/medium/249b02e3368752cb175446082015737c_default.png"},{"username":"vannCN","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13330448/medium/9b8a9ee3611e51e951e22d5fd4eb7d8d.jpg"},{"username":"binswm","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13343482/medium/45dde0e6097b9b72705d2eba9dbbc276_default.png"},{"username":"SkewRam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13496556/medium/188f5c2deb7938eda51eb786cc4539ca.jpeg"},{"username":"raphcatarino","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13547726/medium/b003511e67df13a4b4b5689488fa8099.jpg"},{"username":"shillos5","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13600557/medium/b17650b2b84f829d54fa548c44c1ab27.jpeg"},{"username":"wolong98","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/13641407/medium/f4634edc58c7857a357e5293543c15cf.jpg"},{"username":"spair0039","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14012333/medium/72430e96027c09c19141cac38eae4617.png"},{"username":"MajorMarcin","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14072183/medium/0a197e64bb92b6ea94cecbb607512c45.jpg"},{"username":"KosmoMoustache","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14103535/medium/51c438cdd9f86e8811c75f4869de479c.png"},{"username":"Soochaehwa","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14118689/medium/496a1ce63111547bf455a1e0a7ac75f1_default.png"},{"username":"garryfield","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14187035/medium/520c964706676045f6eeec1b08edc880.jpg"},{"username":"droidenko","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14444264/medium/4aa3a8e824f72bc9e5ec0d8de307095e.jpeg"},{"username":"maathis","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14489950/medium/6eeddf13a757ee35f78f1763f94d95e1.jpeg"},{"username":"bukvam","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14546502/medium/a819dc40a13c57395a17dc46e847d3ce.jpg"},{"username":"gm.cinalli","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14670666/medium/2d466a0fdbda40764526be86c97c0ab4.jpeg"},{"username":"antoine2tt","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14722148/medium/f88d926900862dd59007ea4b3419cb9d.png"},{"username":"cretzen","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14799754/medium/3ba5c1805ec20c000d5810c07985afe9.jpg"},{"username":"ktKongTong","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14817246/medium/97cfc3c028dbdaf85ebd1102da71e58c.jpeg"},{"username":"marinkaberg","avatarUrl":"https://crowdin-static.cf-downloads.crowdin.com/avatar/14861042/medium/2d5c4e62613f03082f3e645fa92efd59.jpeg"},{"username":"djismgaming","avatarUrl":"httpsLine truncated
|
||||
Reference in new issue
Block a user