diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx index e94d2be6a..0db5d2116 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx @@ -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; - const ContainerStateBadge = ({ state }: { state: ContainerState }) => { const t = useScopedI18n("docker.field.state.option"); return ( - + {t(state)} ); diff --git a/apps/tasks/package.json b/apps/tasks/package.json index 89dc63827..7f5b890bf 100644 --- a/apps/tasks/package.json +++ b/apps/tasks/package.json @@ -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", diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index 57537b1e4..abbcebe22 100644 --- a/packages/api/src/router/docker/docker-router.ts +++ b/packages/api/src/router/docker/docker-router.ts @@ -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((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; } diff --git a/packages/api/src/router/test/docker/docker-router.spec.ts b/packages/api/src/router/test/docker/docker-router.spec.ts index 2becec4e6..941e696e6 100644 --- a/packages/api/src/router/test/docker/docker-router.spec.ts +++ b/packages/api/src/router/test/docker/docker-router.spec.ts @@ -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"] }, diff --git a/packages/cron-job-runner/src/index.ts b/packages/cron-job-runner/src/index.ts index e1e851da6..9605ba3c4 100644 --- a/packages/cron-job-runner/src/index.ts +++ b/packages/cron-job-runner/src/index.ts @@ -24,6 +24,7 @@ export const cronJobs = { mediaTranscoding: { preventManualExecution: false }, minecraftServerStatus: { preventManualExecution: false }, networkController: { preventManualExecution: false }, + dockerContainers: { preventManualExecution: false }, } satisfies Record; /** diff --git a/packages/cron-jobs/src/index.ts b/packages/cron-jobs/src/index.ts index b141c7146..64a661b05 100644 --- a/packages/cron-jobs/src/index.ts +++ b/packages/cron-jobs/src/index.ts @@ -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, }); diff --git a/packages/cron-jobs/src/jobs/docker.ts b/packages/cron-jobs/src/jobs/docker.ts new file mode 100644 index 000000000..4f19d341c --- /dev/null +++ b/packages/cron-jobs/src/jobs/docker.ts @@ -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["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 }); + } + }), + ); +}); diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 263ea533f..e5b92a8bd 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -24,5 +24,6 @@ export const widgetKinds = [ "indexerManager", "healthMonitoring", "releases", + "dockerContainers", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/docker/package.json b/packages/docker/package.json index 1de51cd5f..7bf003cd4 100644 --- a/packages/docker/package.json +++ b/packages/docker/package.json @@ -6,6 +6,7 @@ "type": "module", "exports": { ".": "./index.ts", + "./shared": "./src/shared.ts", "./env": "./src/env.ts" }, "typesVersions": { diff --git a/packages/docker/src/shared.ts b/packages/docker/src/shared.ts new file mode 100644 index 000000000..f50bd32c7 --- /dev/null +++ b/packages/docker/src/shared.ts @@ -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; diff --git a/packages/request-handler/src/docker.ts b/packages/request-handler/src/docker.ts new file mode 100644 index 000000000..932d47e67 --- /dev/null +++ b/packages/request-handler/src/docker.ts @@ -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; +} diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 0902cc0f6..e03c79aa3 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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": { diff --git a/packages/widgets/package.json b/packages/widgets/package.json index f3814ec05..757586ea7 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -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", diff --git a/packages/widgets/src/docker/component.tsx b/packages/widgets/src/docker/component.tsx new file mode 100644 index 000000000..a17d365ee --- /dev/null +++ b/packages/widgets/src/docker/component.tsx @@ -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 ( + + {t(state)} + + ); +}; + +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>, +): MRT_ColumnDef[] => [ + { + accessorKey: "name", + header: t("field.name.label"), + Cell({ renderedCellValue, row }) { + return ( + + + + {renderedCellValue} + + + ); + }, + }, + { + accessorKey: "state", + size: 100, + header: t("field.state.label"), + Cell({ row }) { + return ; + }, + }, + { + accessorKey: "cpuUsage", + size: 80, + header: t("field.stats.cpu.label"), + Cell({ row }) { + const cpuUsage = safeValue(row.original.cpuUsage); + + return ( + + {cpuUsage.toFixed(2)}% + + ); + }, + }, + { + accessorKey: "memoryUsage", + size: 80, + header: t("field.stats.memory.label"), + Cell({ row }) { + const bytesUsage = safeValue(row.original.memoryUsage); + + return ( + + {humanFileSize(bytesUsage)} + + ); + }, + }, + { + 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 ( + + + handleActionAsync(row.original.state === "running" ? "stop" : "start")} + > + {row.original.state === "running" ? ( + + ) : ( + + )} + + + + handleActionAsync("restart")}> + + + + + ); + }, + }, +]; + +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 ( + + + + {!isTiny && ( + + + + {t("table.footer", { count: totalContainers.toString() })} + + + {t("table.updated", { when: relativeTime })} + + )} + + ); +} diff --git a/packages/widgets/src/docker/index.ts b/packages/widgets/src/docker/index.ts new file mode 100644 index 000000000..fa738023a --- /dev/null +++ b/packages/widgets/src/docker/index.ts @@ -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")); diff --git a/packages/widgets/src/errors/base-component.tsx b/packages/widgets/src/errors/base-component.tsx index 8c4e12b40..65135acff 100644 --- a/packages/widgets/src/errors/base-component.tsx +++ b/packages/widgets/src/errors/base-component.tsx @@ -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; diff --git a/packages/widgets/src/errors/component.tsx b/packages/widgets/src/errors/component.tsx index 5638ef321..57f77d62a 100644 --- a/packages/widgets/src/errors/component.tsx +++ b/packages/widgets/src/errors/component.tsx @@ -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 ; } - const commonFallbackError = ( + const widgetTrpcErrorData = handleWidgetTrpcError(error, currentDefinition); + if (widgetTrpcErrorData) { + return ; + } + + const trpcErrorData = handleCommonTrpcError(error); + if (trpcErrorData) { + return ; + } + + return ( 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 | 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 = currentDefinition.errors; - const errorDefinition = errors[errorData.code]; + if (!("errors" in currentDefinition) || currentDefinition.errors === undefined) return null; - if (!errorDefinition) return commonFallbackError; + const errors: Exclude = currentDefinition.errors; + const errorDefinition = errors[errorData.code]; - return ( - - ); + if (!errorDefinition) return null; + + return { + ...errorDefinition, + showLogsLink: !errorDefinition.hideLogsLink, + }; +}; + +const handleCommonTrpcError = (error: unknown): Omit | 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; }; diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index df1701741..e8d9cbe1d 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd425ebed..defc3fccb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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