feat: docker widget (#2288)

Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
Co-authored-by: homarr-crowdin[bot] <190541745+homarr-crowdin[bot]@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Yossi Hillali
2025-05-23 21:35:04 +03:00
committed by GitHub
parent 09f4e6785b
commit e1eda534da
19 changed files with 521 additions and 111 deletions

View File

@@ -17,6 +17,7 @@ import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useTimeAgo } from "@homarr/common";
import type { ContainerState } from "@homarr/docker";
import { containerStateColorMap } from "@homarr/docker/shared";
import { useModalAction } from "@homarr/modals";
import { AddDockerAppToHomarr } from "@homarr/modals-collection";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
@@ -244,21 +245,11 @@ const ContainerActionBarButton = (props: ContainerActionBarButtonProps) => {
);
};
const containerStates = {
created: "cyan",
running: "green",
paused: "yellow",
restarting: "orange",
exited: "red",
removing: "pink",
dead: "dark",
} satisfies Record<ContainerState, MantineColor>;
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
return (
<Badge size="lg" radius="sm" variant="light" w={120} color={containerStates[state]}>
<Badge size="lg" radius="sm" variant="light" w={120} color={containerStateColorMap[state]}>
{t(state)}
</Badge>
);

View File

@@ -10,7 +10,7 @@
"main": "./src/main.ts",
"types": "./src/main.ts",
"scripts": {
"build": "esbuild src/main.ts --bundle --platform=node --loader:.scss=text --external:@opentelemetry/api --external:deasync --outfile=tasks.cjs",
"build": "esbuild src/main.ts --bundle --platform=node --loader:.scss=text --external:*.node --external:@opentelemetry/api --external:deasync --outfile=tasks.cjs",
"clean": "rm -rf .turbo node_modules",
"dev": "pnpm with-env tsx ./src/main.ts",
"format": "prettier --check . --ignore-path ../../.gitignore",

View File

@@ -1,90 +1,48 @@
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { z } from "zod";
import { db, like, or } from "@homarr/db";
import { icons } from "@homarr/db/schema";
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
import { DockerSingleton } from "@homarr/docker";
import type { Container, ContainerInfo, ContainerState, Docker, Port } from "@homarr/docker";
import { logger } from "@homarr/log";
import { createCacheChannel } from "@homarr/redis";
import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
import { dockerMiddleware } from "../../middlewares/docker";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
const dockerCache = createCacheChannel<{
containers: (ContainerInfo & { instance: string; iconUrl: string | null })[];
}>("docker-containers", 5 * 60 * 1000);
export const dockerRouter = createTRPCRouter({
getContainers: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.query(async () => {
const result = await dockerCache
.consumeAsync(async () => {
const dockerInstances = DockerSingleton.getInstances();
const containers = await Promise.all(
// Return all the containers of all the instances into only one item
dockerInstances.map(({ instance, host: key }) =>
instance.listContainers({ all: true }).then((containers) =>
containers.map((container) => ({
...container,
instance: key,
})),
),
),
).then((containers) => containers.flat());
const extractImage = (container: ContainerInfo) => container.Image.split("/").at(-1)?.split(":").at(0) ?? "";
const likeQueries = containers.map((container) => like(icons.name, `%${extractImage(container)}%`));
const dbIcons =
likeQueries.length >= 1
? await db.query.icons.findMany({
where: or(...likeQueries),
})
: [];
return {
containers: containers.map((container) => ({
...container,
iconUrl:
dbIcons.find((icon) => {
const extractedImage = extractImage(container);
if (!extractedImage) return false;
return icon.name.toLowerCase().includes(extractedImage.toLowerCase());
})?.url ?? null,
})),
};
})
.catch((error) => {
logger.error(error);
return {
isError: true,
error: error as unknown,
};
});
if ("isError" in result) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "An error occurred while fetching the containers",
cause: result.error,
});
}
const innerHandler = dockerContainersRequestHandler.handler({});
const result = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
const { data, timestamp } = result;
return {
containers: sanitizeContainers(data.containers),
containers: data satisfies DockerContainer[],
timestamp,
};
}),
subscribeContainers: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.subscription(() => {
return observable<DockerContainer[]>((emit) => {
const innerHandler = dockerContainersRequestHandler.handler({});
const unsubscribe = innerHandler.subscribe((data) => {
emit.next(data);
});
return unsubscribe;
});
}),
invalidate: permissionRequiredProcedure
.requiresPermission("admin")
.concat(dockerMiddleware())
.mutation(async () => {
await dockerCache.invalidateAsync();
return;
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}),
startAll: permissionRequiredProcedure
.requiresPermission("admin")
@@ -98,7 +56,8 @@ export const dockerRouter = createTRPCRouter({
}),
);
await dockerCache.invalidateAsync();
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}),
stopAll: permissionRequiredProcedure
.requiresPermission("admin")
@@ -112,7 +71,8 @@ export const dockerRouter = createTRPCRouter({
}),
);
await dockerCache.invalidateAsync();
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}),
restartAll: permissionRequiredProcedure
.requiresPermission("admin")
@@ -126,7 +86,8 @@ export const dockerRouter = createTRPCRouter({
}),
);
await dockerCache.invalidateAsync();
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}),
removeAll: permissionRequiredProcedure
.requiresPermission("admin")
@@ -140,7 +101,8 @@ export const dockerRouter = createTRPCRouter({
}),
);
await dockerCache.invalidateAsync();
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}),
});
@@ -180,20 +142,6 @@ interface DockerContainer {
image: string;
ports: Port[];
iconUrl: string | null;
}
function sanitizeContainers(
containers: (ContainerInfo & { instance: string; iconUrl: string | null })[],
): DockerContainer[] {
return containers.map((container) => {
return {
name: container.Names[0]?.split("/")[1] ?? "Unknown",
id: container.Id,
instance: container.instance,
state: container.State as ContainerState,
image: container.Image,
ports: container.Ports,
iconUrl: container.iconUrl,
};
});
cpuUsage: number;
memoryUsage: number;
}

View File

@@ -4,14 +4,26 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { objectKeys } from "@homarr/common";
import type { Database } from "@homarr/db";
import { getPermissionsWithChildren } from "@homarr/definitions";
import type { GroupPermissionKey } from "@homarr/definitions";
import { getPermissionsWithChildren } from "@homarr/definitions";
import type { RouterInputs } from "../../..";
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", () => ({
dockerContainersRequestHandler: {
handler: () => ({
getCachedOrUpdatedDataAsync: async () => {
return await Promise.resolve({ containers: [] });
},
invalidateAsync: async () => {
return await Promise.resolve();
},
}),
},
}));
vi.mock("@homarr/redis", () => ({
createCacheChannel: () => ({
// eslint-disable-next-line @typescript-eslint/require-await
@@ -22,6 +34,7 @@ vi.mock("@homarr/redis", () => ({
// eslint-disable-next-line @typescript-eslint/no-empty-function
invalidateAsync: async () => {},
}),
createWidgetOptionsChannel: () => ({}),
}));
vi.mock("@homarr/docker/env", () => ({
@@ -46,6 +59,7 @@ const validInputs: {
[key in (typeof procedureKeys)[number]]: RouterInputs["docker"][key];
} = {
getContainers: undefined,
subscribeContainers: undefined,
startAll: { ids: ["1"] },
stopAll: { ids: ["1"] },
restartAll: { ids: ["1"] },

View File

@@ -24,6 +24,7 @@ export const cronJobs = {
mediaTranscoding: { preventManualExecution: false },
minecraftServerStatus: { preventManualExecution: false },
networkController: { preventManualExecution: false },
dockerContainers: { preventManualExecution: false },
} satisfies Record<JobGroupKeys, { preventManualExecution?: boolean }>;
/**

View File

@@ -1,4 +1,5 @@
import { analyticsJob } from "./jobs/analytics";
import { dockerContainersJob } from "./jobs/docker";
import { iconsUpdaterJob } from "./jobs/icons-updater";
import { dnsHoleJob } from "./jobs/integrations/dns-hole";
import { downloadsJob } from "./jobs/integrations/downloads";
@@ -35,6 +36,7 @@ export const jobGroup = createCronJobGroup({
updateChecker: updateCheckerJob,
mediaTranscoding: mediaTranscodingJob,
minecraftServerStatus: minecraftServerStatusJob,
dockerContainers: dockerContainersJob,
networkController: networkControllerJob,
});

View File

@@ -0,0 +1,28 @@
import SuperJSON from "superjson";
import { EVERY_MINUTE } from "@homarr/cron-jobs-core/expressions";
import { db, eq } from "@homarr/db";
import { items } from "@homarr/db/schema";
import { logger } from "@homarr/log";
import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
import type { WidgetComponentProps } from "../../../widgets";
import { createCronJob } from "../lib";
export const dockerContainersJob = createCronJob("dockerContainers", EVERY_MINUTE).withCallback(async () => {
const dockerItems = await db.query.items.findMany({
where: eq(items.kind, "dockerContainers"),
});
await Promise.allSettled(
dockerItems.map(async (item) => {
try {
const options = SuperJSON.parse<WidgetComponentProps<"dockerContainers">["options"]>(item.options);
const innerHandler = dockerContainersRequestHandler.handler(options);
await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
} catch (error) {
logger.error("Failed to update Docker container status", { item, error });
}
}),
);
});

View File

@@ -24,5 +24,6 @@ export const widgetKinds = [
"indexerManager",
"healthMonitoring",
"releases",
"dockerContainers",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];

View File

@@ -6,6 +6,7 @@
"type": "module",
"exports": {
".": "./index.ts",
"./shared": "./src/shared.ts",
"./env": "./src/env.ts"
},
"typesVersions": {

View File

@@ -0,0 +1,13 @@
import type { MantineColor } from "@mantine/core";
import type { ContainerState } from ".";
export const containerStateColorMap = {
created: "cyan",
running: "green",
paused: "yellow",
restarting: "orange",
exited: "red",
removing: "pink",
dead: "dark",
} satisfies Record<ContainerState, MantineColor>;

View File

@@ -0,0 +1,80 @@
import dayjs from "dayjs";
import type { ContainerInfo, ContainerStats } from "dockerode";
import { db, like, or } from "@homarr/db";
import { icons } from "@homarr/db/schema";
import type { ContainerState } from "../../docker/src";
import { DockerSingleton } from "../../docker/src";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
queryKey: "dockerContainersResult",
widgetKind: "dockerContainers",
async requestAsync() {
const containers = await getContainersWithStatsAsync();
return containers;
},
cacheDuration: dayjs.duration(20, "seconds"),
});
const dockerInstances = DockerSingleton.getInstances();
const extractImage = (container: ContainerInfo) => container.Image.split("/").at(-1)?.split(":").at(0) ?? "";
async function getContainersWithStatsAsync() {
const containers = await Promise.all(
dockerInstances.map(async ({ instance, host }) => {
const instanceContainers = await instance.listContainers({ all: true });
return instanceContainers.map((container) => ({
...container,
instance: host,
}));
}),
).then((res) => res.flat());
const likeQueries = containers.map((container) => like(icons.name, `%${extractImage(container)}%`));
const dbIcons =
likeQueries.length > 0
? await db.query.icons.findMany({
where: or(...likeQueries),
})
: [];
const containerStatsPromises = containers.map(async (container) => {
const instance = dockerInstances.find(({ host }) => host === container.instance)?.instance;
if (!instance) return null;
const stats = await instance.getContainer(container.Id).stats({ stream: false });
return {
id: container.Id,
name: container.Names[0]?.split("/")[1] ?? "Unknown",
state: container.State as ContainerState,
iconUrl:
dbIcons.find((icon) => {
const extractedImage = extractImage(container);
if (!extractedImage) return false;
return icon.name.toLowerCase().includes(extractedImage.toLowerCase());
})?.url ?? null,
cpuUsage: calculateCpuUsage(stats),
memoryUsage: stats.memory_stats.usage,
image: container.Image,
ports: container.Ports,
};
});
return (await Promise.all(containerStatsPromises)).filter((container) => container !== null);
}
function calculateCpuUsage(stats: ContainerStats): number {
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
const numberOfCpus = stats.cpu_stats.online_cpus || 1;
if (systemDelta === 0) return 0;
return (cpuDelta / systemDelta) * numberOfCpus * 100;
}

View File

@@ -1832,6 +1832,14 @@
}
}
},
"dockerContainers": {
"name": "Docker stats",
"description": "Stats of your containers (This widget can only be added with administrator privileges)",
"option": {},
"error": {
"internalServerError": "Failed to fetch containers stats"
}
},
"common": {
"location": {
"query": "City / Postal code",
@@ -3091,6 +3099,9 @@
},
"networkController": {
"label": "Network Controller"
},
"dockerContainers": {
"label": "Docker containers"
}
}
},
@@ -3154,8 +3165,9 @@
"title": "Containers",
"table": {
"updated": "Updated {when}",
"search": "Search {count} containers",
"selected": "{selectCount} of {totalCount} containers selected"
"search": "Seach {count} containers",
"selected": "{selectCount} of {totalCount} containers selected",
"footer": "Total {count} containers"
},
"field": {
"name": {
@@ -3173,6 +3185,14 @@
"dead": "Dead"
}
},
"stats": {
"cpu": {
"label": "CPU"
},
"memory": {
"label": "Memory"
}
},
"containerImage": {
"label": "Image"
},
@@ -3181,6 +3201,7 @@
}
},
"action": {
"title": "Actions",
"start": {
"label": "Start",
"notification": {

View File

@@ -33,6 +33,7 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0",
"@homarr/form": "workspace:^0.1.0",
"@homarr/forms-collection": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",

View File

@@ -0,0 +1,256 @@
"use client";
import { useMemo } from "react";
import { ActionIcon, Avatar, Badge, Group, Stack, Text, Tooltip } from "@mantine/core";
import type { IconProps } from "@tabler/icons-react";
import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRotateClockwise } from "@tabler/icons-react";
import type { MRT_ColumnDef } from "mantine-react-table";
import { MantineReactTable } from "mantine-react-table";
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 } from "@homarr/docker/shared";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import type { WidgetComponentProps } from "../definition";
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
return (
<Badge size="xs" radius="sm" variant="light" color={containerStateColorMap[state]}>
{t(state)}
</Badge>
);
};
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)",
};
const createColumns = (
t: ReturnType<typeof useScopedI18n<"docker">>,
): MRT_ColumnDef<RouterOutputs["docker"]["getContainers"]["containers"][number]>[] => [
{
accessorKey: "name",
header: t("field.name.label"),
Cell({ renderedCellValue, row }) {
return (
<Group gap="xs" wrap="nowrap">
<Avatar variant="outline" radius="md" size={20} src={row.original.iconUrl} />
<Text p="0.5" size="sm" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>
{renderedCellValue}
</Text>
</Group>
);
},
},
{
accessorKey: "state",
size: 100,
header: t("field.state.label"),
Cell({ row }) {
return <ContainerStateBadge state={row.original.state} />;
},
},
{
accessorKey: "cpuUsage",
size: 80,
header: t("field.stats.cpu.label"),
Cell({ row }) {
const cpuUsage = safeValue(row.original.cpuUsage);
return (
<Text size="xs" c={cpuUsageColor(cpuUsage, row.original.state)}>
{cpuUsage.toFixed(2)}%
</Text>
);
},
},
{
accessorKey: "memoryUsage",
size: 80,
header: t("field.stats.memory.label"),
Cell({ row }) {
const bytesUsage = safeValue(row.original.memoryUsage);
return (
<Text size="xs" c={memoryUsageColor(bytesUsage, row.original.state)}>
{humanFileSize(bytesUsage)}
</Text>
);
},
},
{
accessorKey: "actions",
size: 80,
header: t("action.title"),
Cell({ row }) {
const utils = clientApi.useUtils();
const { mutateAsync: startContainer } = clientApi.docker.startAll.useMutation();
const { mutateAsync: stopContainer } = clientApi.docker.stopAll.useMutation();
const { mutateAsync: restartContainer } = clientApi.docker.restartAll.useMutation();
const handleActionAsync = async (action: "start" | "stop" | "restart") => {
const mutation = action === "start" ? startContainer : action === "stop" ? stopContainer : restartContainer;
await mutation(
{ ids: [row.original.id] },
{
async onSettled() {
await utils.docker.getContainers.invalidate();
},
onSuccess() {
showSuccessNotification({
title: t(`action.${action}.notification.success.title`),
message: t(`action.${action}.notification.success.message`),
});
},
onError() {
showErrorNotification({
title: t(`action.${action}.notification.error.title`),
message: t(`action.${action}.notification.error.message`),
});
},
},
);
};
return (
<Group wrap="nowrap" gap="xs">
<Tooltip label={row.original.state === "running" ? t("action.stop.label") : t("action.start.label")}>
<ActionIcon
variant="subtle"
size="xs"
radius="100%"
onClick={() => handleActionAsync(row.original.state === "running" ? "stop" : "start")}
>
{row.original.state === "running" ? (
<IconPlayerStop style={actionIconIconStyle} />
) : (
<IconPlayerPlay style={actionIconIconStyle} />
)}
</ActionIcon>
</Tooltip>
<Tooltip label={t("action.restart.label")}>
<ActionIcon variant="subtle" size="xs" radius="100%" onClick={() => handleActionAsync("restart")}>
<IconRotateClockwise style={actionIconIconStyle} />
</ActionIcon>
</Tooltip>
</Group>
);
},
},
];
export default function DockerWidget({ width }: WidgetComponentProps<"dockerContainers">) {
const t = useScopedI18n("docker");
const isTiny = width <= 256;
const utils = clientApi.useUtils();
const [{ containers, timestamp }] = clientApi.docker.getContainers.useSuspenseQuery();
const relativeTime = useTimeAgo(timestamp);
clientApi.docker.subscribeContainers.useSubscription(undefined, {
onData(data) {
utils.docker.getContainers.setData(undefined, { containers: data, timestamp: new Date() });
},
});
const totalContainers = containers.length;
const columns = useMemo(() => createColumns(t), [t]);
const table = useTranslatedMantineReactTable({
columns,
data: containers,
enablePagination: false,
enableTopToolbar: false,
enableBottomToolbar: false,
enableSorting: false,
enableColumnActions: false,
enableStickyHeader: false,
enableColumnOrdering: false,
enableRowSelection: false,
enableFullScreenToggle: false,
enableGlobalFilter: false,
enableDensityToggle: false,
enableFilters: false,
enableHiding: false,
initialState: {
density: "xs",
},
mantinePaperProps: {
flex: 1,
withBorder: false,
shadow: undefined,
},
mantineTableProps: {
className: "docker-widget-table",
style: {
tableLayout: "fixed",
},
},
mantineTableHeadProps: {
fz: "xs",
},
mantineTableHeadCellProps: {
p: 4,
},
mantineTableBodyCellProps: {
p: 4,
},
mantineTableContainerProps: {
style: {
height: "100%",
},
},
});
return (
<Stack gap={0} h="100%" display="flex">
<MantineReactTable table={table} />
{!isTiny && (
<Group
justify="space-between"
style={{
borderTop: "0.0625rem solid var(--border-color)",
}}
p={4}
>
<Group gap={4}>
<IconBrandDocker size={20} />
<Text size="sm">{t("table.footer", { count: totalContainers.toString() })}</Text>
</Group>
<Text size="sm">{t("table.updated", { when: relativeTime })}</Text>
</Group>
)}
</Stack>
);
}

View File

@@ -0,0 +1,17 @@
import { IconBrandDocker, IconServerOff } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker,
createOptions() {
return optionsBuilder.from(() => ({}));
},
errors: {
INTERNAL_SERVER_ERROR: {
icon: IconServerOff,
message: (t) => t("widget.dockerContainers.error.internalServerError"),
},
},
}).withDynamicImport(() => import("./component"));

View File

@@ -6,7 +6,7 @@ import { translateIfNecessary } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
interface BaseWidgetErrorProps {
export interface BaseWidgetErrorProps {
icon: TablerIcon;
message: stringOrTranslation;
showLogsLink?: boolean;

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { IconExclamationCircle } from "@tabler/icons-react";
import { IconExclamationCircle, IconShield } from "@tabler/icons-react";
import { TRPCClientError } from "@trpc/client";
import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import";
@@ -8,6 +8,7 @@ import type { WidgetKind } from "@homarr/definitions";
import type { WidgetDefinition } from "..";
import { widgetImports } from "..";
import { ErrorBoundaryError } from "./base";
import type { BaseWidgetErrorProps } from "./base-component";
import { BaseWidgetError } from "./base-component";
interface WidgetErrorProps {
@@ -23,28 +24,58 @@ export const WidgetError = ({ error, resetErrorBoundary, kind }: WidgetErrorProp
return <BaseWidgetError {...error.getErrorBoundaryData()} onRetry={resetErrorBoundary} />;
}
const commonFallbackError = (
const widgetTrpcErrorData = handleWidgetTrpcError(error, currentDefinition);
if (widgetTrpcErrorData) {
return <BaseWidgetError {...widgetTrpcErrorData} onRetry={resetErrorBoundary} />;
}
const trpcErrorData = handleCommonTrpcError(error);
if (trpcErrorData) {
return <BaseWidgetError {...trpcErrorData} onRetry={resetErrorBoundary} />;
}
return (
<BaseWidgetError
icon={IconExclamationCircle}
message={(error as { toString: () => string }).toString()}
onRetry={resetErrorBoundary}
/>
);
};
if (error instanceof TRPCClientError && "code" in error.data) {
const errorData = error.data as DefaultErrorData;
const handleWidgetTrpcError = (
error: unknown,
currentDefinition: WidgetDefinition,
): Omit<BaseWidgetErrorProps, "onRetry"> | null => {
if (!(error instanceof TRPCClientError && "code" in error.data)) return null;
if (!("errors" in currentDefinition)) return commonFallbackError;
const errorData = error.data as DefaultErrorData;
const errors: Exclude<WidgetDefinition["errors"], undefined> = currentDefinition.errors;
const errorDefinition = errors[errorData.code];
if (!("errors" in currentDefinition) || currentDefinition.errors === undefined) return null;
if (!errorDefinition) return commonFallbackError;
const errors: Exclude<WidgetDefinition["errors"], undefined> = currentDefinition.errors;
const errorDefinition = errors[errorData.code];
return (
<BaseWidgetError {...errorDefinition} onRetry={resetErrorBoundary} showLogsLink={!errorDefinition.hideLogsLink} />
);
if (!errorDefinition) return null;
return {
...errorDefinition,
showLogsLink: !errorDefinition.hideLogsLink,
};
};
const handleCommonTrpcError = (error: unknown): Omit<BaseWidgetErrorProps, "onRetry"> | null => {
if (!(error instanceof TRPCClientError && "code" in error.data)) return null;
const errorData = error.data as DefaultErrorData;
if (errorData.code === "UNAUTHORIZED" || errorData.code === "FORBIDDEN") {
return {
icon: IconShield,
message: "You don't have permission to access this widget",
showLogsLink: false,
};
}
return commonFallbackError;
return null;
};

View File

@@ -14,6 +14,7 @@ import * as clock from "./clock";
import type { WidgetComponentProps } from "./definition";
import * as dnsHoleControls from "./dns-hole/controls";
import * as dnsHoleSummary from "./dns-hole/summary";
import * as dockerContainers from "./docker";
import * as downloads from "./downloads";
import * as healthMonitoring from "./health-monitoring";
import * as iframe from "./iframe";
@@ -64,6 +65,7 @@ export const widgetImports = {
healthMonitoring,
mediaTranscoding,
minecraftServerStatus,
dockerContainers,
releases,
} satisfies WidgetImportRecord;

3
pnpm-lock.yaml generated
View File

@@ -2075,6 +2075,9 @@ importers:
'@homarr/definitions':
specifier: workspace:^0.1.0
version: link:../definitions
'@homarr/docker':
specifier: workspace:^0.1.0
version: link:../docker
'@homarr/form':
specifier: workspace:^0.1.0
version: link:../form