From 96b05795f35e204ba97fa958ad87067d9fa03f95 Mon Sep 17 00:00:00 2001 From: oussama Dahmaz Date: Sun, 12 Jan 2025 17:06:40 +0100 Subject: [PATCH] feat(kubernetes): add kubernetes tool --- .env.example | 5 +- .../public/images/kubernetes/configmaps.svg | 141 +++++++++ .../public/images/kubernetes/ingresses.svg | 84 ++++++ .../public/images/kubernetes/namespaces.svg | 85 ++++++ .../nextjs/public/images/kubernetes/nodes.svg | 84 ++++++ apps/nextjs/public/images/kubernetes/pods.svg | 103 +++++++ .../public/images/kubernetes/secrets.svg | 128 +++++++++ .../public/images/kubernetes/services.svg | 117 ++++++++ .../public/images/kubernetes/volumes.svg | 97 +++++++ .../nextjs/src/app/[locale]/manage/layout.tsx | 9 +- .../app/[locale]/manage/tools/docker/page.tsx | 3 +- .../cluster-dashboard/cluster-dashboard.tsx | 76 +++++ .../kubernetes/cluster-dashboard/error.tsx | 27 ++ .../header-card/header-card.module.css | 31 ++ .../header-card/header-card.tsx | 44 +++ .../header-card/header-icon.tsx | 18 ++ .../resource-gauge/resource-gauge.module.css | 20 ++ .../resource-gauge/resource-gauge.tsx | 90 ++++++ .../resource-gauge/resource-icon.tsx | 21 ++ .../resource-tile/resource-tile.module.css | 10 + .../resource-tile/resource-tile.tsx | 37 +++ .../configmaps/configmaps-table.tsx | 74 +++++ .../tools/kubernetes/configmaps/page.tsx | 30 ++ .../kubernetes/ingresses/ingresses-table.tsx | 107 +++++++ .../tools/kubernetes/ingresses/page.tsx | 29 ++ .../namespaces/namespaces-table.tsx | 91 ++++++ .../tools/kubernetes/namespaces/page.tsx | 29 ++ .../tools/kubernetes/nodes/nodes-table.tsx | 132 +++++++++ .../manage/tools/kubernetes/nodes/page.tsx | 29 ++ .../[locale]/manage/tools/kubernetes/page.tsx | 21 ++ .../manage/tools/kubernetes/pods/page.tsx | 29 ++ .../tools/kubernetes/pods/pods-table.tsx | 82 ++++++ .../manage/tools/kubernetes/secrets/page.tsx | 29 ++ .../kubernetes/secrets/secrets-table.tsx | 77 +++++ .../manage/tools/kubernetes/services/page.tsx | 29 ++ .../kubernetes/services/services-table.tsx | 95 ++++++ .../manage/tools/kubernetes/volumes/page.tsx | 29 ++ .../kubernetes/volumes/volumes-table.tsx | 101 +++++++ development/development.docker-compose.yml | 18 +- packages/api/package.json | 1 + packages/api/src/env.ts | 15 + packages/api/src/middlewares/docker.ts | 17 ++ packages/api/src/middlewares/kubernetes.ts | 17 ++ packages/api/src/root.ts | 2 + .../api/src/router/docker/docker-router.ts | 135 +++++---- .../router/kubernetes/kubernetes-client.ts | 73 +++++ .../resource-parser/cpu-resource-parser.ts | 41 +++ .../resource-parser/memory-resource-parser.ts | 69 +++++ .../resource-parser/resource-parser.ts | 3 + .../src/router/kubernetes/router/cluster.ts | 196 +++++++++++++ .../router/kubernetes/router/configMaps.ts | 36 +++ .../src/router/kubernetes/router/ingresses.ts | 54 ++++ .../kubernetes/router/kubernetes-router.ts | 22 ++ .../router/kubernetes/router/namespaces.ts | 36 +++ .../api/src/router/kubernetes/router/nodes.ts | 68 +++++ .../api/src/router/kubernetes/router/pods.ts | 104 +++++++ .../src/router/kubernetes/router/secrets.ts | 36 +++ .../src/router/kubernetes/router/services.ts | 40 +++ .../src/router/kubernetes/router/volumes.ts | 42 +++ .../router/test/docker/docker-router.spec.ts | 6 + .../cpu-resource-parser.spec.ts | 56 ++++ .../memory-resource-parser.spec.ts | 61 ++++ packages/definitions/src/index.ts | 1 + packages/definitions/src/kubernetes.ts | 110 +++++++ packages/docker/src/env.ts | 3 + packages/translation/src/lang/en.json | 270 +++++++++++++++++- pnpm-lock.yaml | 197 ++++++++++++- 67 files changed, 3901 insertions(+), 71 deletions(-) create mode 100644 apps/nextjs/public/images/kubernetes/configmaps.svg create mode 100644 apps/nextjs/public/images/kubernetes/ingresses.svg create mode 100644 apps/nextjs/public/images/kubernetes/namespaces.svg create mode 100644 apps/nextjs/public/images/kubernetes/nodes.svg create mode 100644 apps/nextjs/public/images/kubernetes/pods.svg create mode 100644 apps/nextjs/public/images/kubernetes/secrets.svg create mode 100644 apps/nextjs/public/images/kubernetes/services.svg create mode 100644 apps/nextjs/public/images/kubernetes/volumes.svg create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/cluster-dashboard.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/error.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-card.module.css create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-card.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-icon.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-gauge.module.css create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-gauge.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-icon.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-tile/resource-tile.module.css create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-tile/resource-tile.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/page.tsx create mode 100644 apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx create mode 100644 packages/api/src/env.ts create mode 100644 packages/api/src/middlewares/docker.ts create mode 100644 packages/api/src/middlewares/kubernetes.ts create mode 100644 packages/api/src/router/kubernetes/kubernetes-client.ts create mode 100644 packages/api/src/router/kubernetes/resource-parser/cpu-resource-parser.ts create mode 100644 packages/api/src/router/kubernetes/resource-parser/memory-resource-parser.ts create mode 100644 packages/api/src/router/kubernetes/resource-parser/resource-parser.ts create mode 100644 packages/api/src/router/kubernetes/router/cluster.ts create mode 100644 packages/api/src/router/kubernetes/router/configMaps.ts create mode 100644 packages/api/src/router/kubernetes/router/ingresses.ts create mode 100644 packages/api/src/router/kubernetes/router/kubernetes-router.ts create mode 100644 packages/api/src/router/kubernetes/router/namespaces.ts create mode 100644 packages/api/src/router/kubernetes/router/nodes.ts create mode 100644 packages/api/src/router/kubernetes/router/pods.ts create mode 100644 packages/api/src/router/kubernetes/router/secrets.ts create mode 100644 packages/api/src/router/kubernetes/router/services.ts create mode 100644 packages/api/src/router/kubernetes/router/volumes.ts create mode 100644 packages/api/src/router/test/kubernetes/resource-parser/cpu-resource-parser.spec.ts create mode 100644 packages/api/src/router/test/kubernetes/resource-parser/memory-resource-parser.spec.ts create mode 100644 packages/definitions/src/kubernetes.ts diff --git a/.env.example b/.env.example index fbc491bae..ae3d1e38b 100644 --- a/.env.example +++ b/.env.example @@ -34,4 +34,7 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE' # If it is used, please use the full path to the directory where the certificates are stored. # LOCAL_CERTIFICATE_PATH='FULL_PATH_TO_CERTIFICATES' -TURBO_TELEMETRY_DISABLED=1 \ No newline at end of file +TURBO_TELEMETRY_DISABLED=1 + +# Enable kubernetes tool +# ENABLE_KUBERNETES=true \ No newline at end of file diff --git a/apps/nextjs/public/images/kubernetes/configmaps.svg b/apps/nextjs/public/images/kubernetes/configmaps.svg new file mode 100644 index 000000000..85ac9b476 --- /dev/null +++ b/apps/nextjs/public/images/kubernetes/configmaps.svg @@ -0,0 +1,141 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/nextjs/public/images/kubernetes/ingresses.svg b/apps/nextjs/public/images/kubernetes/ingresses.svg new file mode 100644 index 000000000..0dde27514 --- /dev/null +++ b/apps/nextjs/public/images/kubernetes/ingresses.svg @@ -0,0 +1,84 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/apps/nextjs/public/images/kubernetes/namespaces.svg b/apps/nextjs/public/images/kubernetes/namespaces.svg new file mode 100644 index 000000000..231c21c9e --- /dev/null +++ b/apps/nextjs/public/images/kubernetes/namespaces.svg @@ -0,0 +1,85 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/apps/nextjs/public/images/kubernetes/nodes.svg b/apps/nextjs/public/images/kubernetes/nodes.svg new file mode 100644 index 000000000..c0f2b8e13 --- /dev/null +++ b/apps/nextjs/public/images/kubernetes/nodes.svg @@ -0,0 +1,84 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/apps/nextjs/public/images/kubernetes/pods.svg b/apps/nextjs/public/images/kubernetes/pods.svg new file mode 100644 index 000000000..f88d2dbca --- /dev/null +++ b/apps/nextjs/public/images/kubernetes/pods.svg @@ -0,0 +1,103 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/apps/nextjs/public/images/kubernetes/secrets.svg b/apps/nextjs/public/images/kubernetes/secrets.svg new file mode 100644 index 000000000..195727e1e --- /dev/null +++ b/apps/nextjs/public/images/kubernetes/secrets.svg @@ -0,0 +1,128 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/nextjs/public/images/kubernetes/services.svg b/apps/nextjs/public/images/kubernetes/services.svg new file mode 100644 index 000000000..779b61405 --- /dev/null +++ b/apps/nextjs/public/images/kubernetes/services.svg @@ -0,0 +1,117 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/nextjs/public/images/kubernetes/volumes.svg b/apps/nextjs/public/images/kubernetes/volumes.svg new file mode 100644 index 000000000..dba1bd2d7 --- /dev/null +++ b/apps/nextjs/public/images/kubernetes/volumes.svg @@ -0,0 +1,97 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/apps/nextjs/src/app/[locale]/manage/layout.tsx b/apps/nextjs/src/app/[locale]/manage/layout.tsx index 0dc505799..32013221c 100644 --- a/apps/nextjs/src/app/[locale]/manage/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/layout.tsx @@ -28,6 +28,7 @@ import { import { auth } from "@homarr/auth/next"; import { isProviderEnabled } from "@homarr/auth/server"; import { createDocumentationLink } from "@homarr/definitions"; +import { env } from "@homarr/docker/env"; import { getScopedI18n } from "@homarr/translation/server"; import { MainHeader } from "~/components/layout/header"; @@ -113,7 +114,13 @@ export default async function ManageLayout({ children }: PropsWithChildren) { label: t("items.tools.items.docker"), icon: IconBrandDocker, href: "/manage/tools/docker", - hidden: !session?.user.permissions.includes("admin"), + hidden: !(session?.user.permissions.includes("admin") && env.ENABLE_DOCKER), + }, + { + label: t("items.tools.items.kubernetes"), + icon: IconBox, + href: "/manage/tools/kubernetes", + hidden: !(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES), }, { label: t("items.tools.items.api"), diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx index 7af012d53..1da3db128 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/page.tsx @@ -3,6 +3,7 @@ import { Stack, Title } from "@mantine/core"; import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/docker/env"; import { getScopedI18n } from "@homarr/translation/server"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; @@ -10,7 +11,7 @@ import { DockerTable } from "./docker-table"; export default async function DockerPage() { const session = await auth(); - if (!session?.user || !session.user.permissions.includes("admin")) { + if (!(session?.user.permissions.includes("admin") && env.ENABLE_DOCKER)) { notFound(); } diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/cluster-dashboard.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/cluster-dashboard.tsx new file mode 100644 index 000000000..ccf0cc556 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/cluster-dashboard.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { SimpleGrid, Skeleton, Stack, Title } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { createId } from "@homarr/db/client"; +import type { KubernetesLabelResourceType } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; + +import KubernetesErrorPage from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/error"; +import { HeaderCard } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-card"; +import { ResourceGauge } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-gauge"; +import { ResourceTile } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-tile/resource-tile"; + +export function ClusterDashboard() { + const t = useI18n(); + + const { + data: clusterData, + isLoading: isClusterLoading, + isError: isClusterError, + } = clientApi.kubernetes.cluster.getCluster.useQuery(); + + const { + data: resourceCountsData, + isLoading: isResourceCountsLoading, + isError: isResourceCountsError, + } = clientApi.kubernetes.cluster.getClusterResourceCounts.useQuery(); + + return ( + + {t("kubernetes.cluster.title")} + + {isClusterError ? ( + Array.from({ length: 3 }).map(() => ) + ) : isClusterLoading ? ( + Array.from({ length: 3 }).map(() => ) + ) : ( + <> + + + + + )} + + + {t("kubernetes.cluster.capacity.title")} + + + {isClusterError + ? Array.from({ length: 3 }).map(() => ) + : isClusterLoading + ? Array.from({ length: 3 }).map(() => ) + : clusterData?.capacity.map((capacity) => ( + + ))} + + + {t("kubernetes.cluster.resources.title")} + + + {isResourceCountsError + ? Array.from({ length: 8 }).map(() => ) + : isResourceCountsLoading + ? Array.from({ length: 8 }).map(() => ) + : resourceCountsData?.map((clusterResourceCount) => ( + + ))} + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/error.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/error.tsx new file mode 100644 index 000000000..4baae5ace --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/error.tsx @@ -0,0 +1,27 @@ +"use client"; + +import Link from "next/link"; +import { Anchor, Center, Stack, Text } from "@mantine/core"; +import { IconCubeOff } from "@tabler/icons-react"; + +import { useI18n } from "@homarr/translation/client"; + +export default function KubernetesErrorPage() { + const t = useI18n(); + + return ( +
+ + + + + {t("kubernetes.error.internalServerError")} + + + {t("common.action.checkLogs")} + + + +
+ ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-card.module.css b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-card.module.css new file mode 100644 index 000000000..b439e7f4b --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-card.module.css @@ -0,0 +1,31 @@ +.header { + direction: inherit; + position: relative; + overflow: hidden; + padding: var(--mantine-spacing-xs); + padding-left: calc(var(--mantine-spacing-xs) * 2); + + @mixin hover { + box-shadow: var(--mantine-shadow-md); + } + + &::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 6px; + background-image: linear-gradient(0, var(--mantine-color-blue-4), var(--mantine-color-blue-9)); + } + + @mixin rtl { + padding-left: var(--mantine-spacing-xs); + padding-right: calc(var(--mantine-spacing-xs) * 2); + + &::before { + left: unset; + right: 0; + } + } +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-card.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-card.tsx new file mode 100644 index 000000000..a8704e1b5 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-card.tsx @@ -0,0 +1,44 @@ +import { Card, Flex, Text, ThemeIcon } from "@mantine/core"; + +import { isLocaleRTL } from "@homarr/translation"; +import { useCurrentLocale, useI18n } from "@homarr/translation/client"; + +import { HeaderIcon } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-icon"; +import classes from "./header-card.module.css"; + +export type HeaderTypes = "providers" | "version" | "architecture"; + +interface HeaderCardProps { + headerType: HeaderTypes; + value: string; +} + +export function HeaderCard(props: HeaderCardProps) { + const t = useI18n(); + const currentLocale = useCurrentLocale(); + const isRTL = isLocaleRTL(currentLocale); + + return ( + + + + + + + {isRTL + ? `${props.value} : ${t(`kubernetes.cluster.${props.headerType}`)}` + : `${t(`kubernetes.cluster.${props.headerType}`)} : ${props.value}`} + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-icon.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-icon.tsx new file mode 100644 index 000000000..483b937c8 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-icon.tsx @@ -0,0 +1,18 @@ +import { IconBrandGit, IconCloudShare, IconGeometry } from "@tabler/icons-react"; + +import type { HeaderTypes } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/header-card/header-card"; + +interface HeaderIconProps { + type: HeaderTypes; +} + +export function HeaderIcon({ type }: HeaderIconProps) { + switch (type) { + case "providers": + return ; + case "version": + return ; + default: + return ; + } +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-gauge.module.css b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-gauge.module.css new file mode 100644 index 000000000..dd620b9c8 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-gauge.module.css @@ -0,0 +1,20 @@ +.paper { + position: relative; + overflow: visible; + padding: var(--mantine-spacing-xl); + padding-top: calc(var(--mantine-spacing-xl) * 1.5 + 20px); +} + +.icon { + position: absolute; + top: -20px; + left: calc(50% - 30px); + border: groove white; +} + +.title { + font-family: + Greycliff CF, + var(--mantine-font-family); + line-height: 1; +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-gauge.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-gauge.tsx new file mode 100644 index 000000000..95143178d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-gauge.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { Group, Paper, Progress, Text, ThemeIcon } from "@mantine/core"; + +import type { KubernetesCapacity } from "@homarr/definitions"; +import { isLocaleRTL } from "@homarr/translation"; +import { useCurrentLocale, useI18n } from "@homarr/translation/client"; + +import { ResourceIcon } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-icon"; +import classes from "./resource-gauge.module.css"; + +interface KubernetesResourceGaugeProps { + kubernetesCapacity: KubernetesCapacity; +} + +export function ResourceGauge(props: KubernetesResourceGaugeProps) { + const t = useI18n(); + const currentLocale = useCurrentLocale(); + const isRTL = Boolean(isLocaleRTL(currentLocale)); + + return ( + + + + + + + {props.kubernetesCapacity.type} + + + {props.kubernetesCapacity.resourcesStats.map((stat) => { + const isReserved = stat.type === "Reserved"; + const labelKey = isReserved + ? "kubernetes.cluster.capacity.resource.reserved" + : "kubernetes.cluster.capacity.resource.used"; + + return ( +
+ + + {isRTL ? ( + <> + {stat.capacityUnit && ( + + {stat.capacityUnit} + + )} + + {stat.maxUsedValue} / {stat.usedValue}{" "} + + + {t(labelKey)} + + + ) : ( + <> + + {t(labelKey)} + + + {" "} + {stat.usedValue} / {stat.maxUsedValue}{" "} + + {stat.capacityUnit && ( + + {stat.capacityUnit} + + )} + + )} + + + {isRTL ? `%${stat.percentageValue}` : `${stat.percentageValue}%`} + + + +
+ ); + })} +
+ ); +} + +function getProgressBarColor(value: number): string { + if (value > 50 && value < 65) { + return "yellow"; + } else if (value >= 65) { + return "red"; + } + return "blue"; +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-icon.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-icon.tsx new file mode 100644 index 000000000..b9f5f435c --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-gauge/resource-icon.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { IconCpu, IconCube, IconDeviceDesktopAnalytics } from "@tabler/icons-react"; + +import type { KubernetesCapacityType } from "@homarr/definitions"; + +const resourceIcons = { + CPU: IconCpu, + Memory: IconDeviceDesktopAnalytics, + Pods: IconCube, +} satisfies Record>; + +interface ResourceIconProps { + type: KubernetesCapacityType; + size?: number; + stroke?: number; +} + +export const ResourceIcon: React.FC = ({ type, size = 32, stroke = 1.5 }) => { + const Icon = resourceIcons[type]; + return ; +}; diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-tile/resource-tile.module.css b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-tile/resource-tile.module.css new file mode 100644 index 000000000..018eaaab6 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-tile/resource-tile.module.css @@ -0,0 +1,10 @@ +.cardContainer { + transition: + box-shadow 150ms ease, + transform 100ms ease; + + @mixin hover { + box-shadow: var(--mantine-shadow-md); + transform: scale(1.02); + } +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-tile/resource-tile.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-tile/resource-tile.tsx new file mode 100644 index 000000000..f1ad19df8 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/cluster-dashboard/resource-tile/resource-tile.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { Card, Group, Text } from "@mantine/core"; +import { IconArrowRight } from "@tabler/icons-react"; + +import type { KubernetesLabelResourceType } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; + +import classes from "./resource-tile.module.css"; + +interface ResourceTileProps { + count: number; + label: KubernetesLabelResourceType; +} + +export function ResourceTile(props: ResourceTileProps) { + const t = useI18n(); + return ( + + + {props.label} + + + {props.count} {t(`kubernetes.cluster.resources.${props.label}`)} + + + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx new file mode 100644 index 000000000..ac144ee15 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table.tsx @@ -0,0 +1,74 @@ +"use client"; + +import React from "react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +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 type { KubernetesBaseResource } from "@homarr/definitions"; +import type { ScopedTranslationFunction } from "@homarr/translation"; +import { useScopedI18n } from "@homarr/translation/client"; +import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; + +dayjs.extend(relativeTime); + +interface ConfigMapsTableComponentProps { + initialConfigMaps: RouterOutputs["kubernetes"]["configMaps"]["getConfigMaps"]; +} + +const createColumns = ( + t: ScopedTranslationFunction<"kubernetes.configmaps">, +): MRT_ColumnDef[] => [ + { + accessorKey: "name", + header: t("field.name.label"), + enableClickToCopy: true, + }, + { + accessorKey: "namespace", + header: t("field.namespace.label"), + enableClickToCopy: true, + }, + { + accessorKey: "creationTimestamp", + header: t("field.creationTimestamp.label"), + Cell: ({ row }) => dayjs(row.original.creationTimestamp).fromNow(false), + }, +]; + +export function ConfigmapsTable(initialData: ConfigMapsTableComponentProps) { + const tConfigMaps = useScopedI18n("kubernetes.configmaps"); + + const { data } = clientApi.kubernetes.configMaps.getConfigMaps.useQuery(undefined, { + initialData: initialData.initialConfigMaps, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + const table = useTranslatedMantineReactTable({ + data, + enableDensityToggle: false, + enableColumnActions: false, + enableColumnFilters: false, + enablePagination: false, + enableRowSelection: true, + positionToolbarAlertBanner: "top", + enableTableFooter: false, + enableBottomToolbar: false, + positionGlobalFilter: "right", + initialState: { density: "xs", showGlobalFilter: true }, + mantineSearchTextInputProps: { + placeholder: tConfigMaps("table.search", { count: data.length }), + style: { minWidth: 300 }, + autoFocus: true, + }, + + columns: createColumns(tConfigMaps), + }); + + return ; +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/page.tsx new file mode 100644 index 000000000..ef49e5f21 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/configmaps/page.tsx @@ -0,0 +1,30 @@ +import { notFound } from "next/navigation"; +import { Stack, Title } from "@mantine/core"; + +import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/docker/env"; +import { getScopedI18n } from "@homarr/translation/server"; + +import { ConfigmapsTable } from "~/app/[locale]/manage/tools/kubernetes/configmaps/configmaps-table"; +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; + +export default async function ConfigMapsPage() { + const session = await auth(); + if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { + notFound(); + } + + const configMaps = await api.kubernetes.configMaps.getConfigMaps(); + const tConfigMaps = await getScopedI18n("kubernetes.configmaps"); + + return ( + <> + + + {tConfigMaps("label")} + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx new file mode 100644 index 000000000..e53e492eb --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table.tsx @@ -0,0 +1,107 @@ +"use client"; + +import React from "react"; +import { Anchor, Flex } from "@mantine/core"; +import { IconArrowRight } from "@tabler/icons-react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +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 { createId } from "@homarr/db/client"; +import type { KubernetesIngress } from "@homarr/definitions"; +import type { ScopedTranslationFunction } from "@homarr/translation"; +import { useScopedI18n } from "@homarr/translation/client"; +import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; + +dayjs.extend(relativeTime); + +interface IngressesTableComponentProps { + initialIngresses: RouterOutputs["kubernetes"]["ingresses"]["getIngresses"]; +} + +const createColumns = (t: ScopedTranslationFunction<"kubernetes.ingresses">): MRT_ColumnDef[] => [ + { + accessorKey: "name", + header: t("field.name.label"), + enableClickToCopy: true, + }, + { + accessorKey: "namespace", + header: t("field.namespace.label"), + enableClickToCopy: true, + }, + { + accessorKey: "className", + header: t("field.className.label"), + enableClickToCopy: true, + }, + { + accessorKey: "rulesAndPaths", + header: t("field.rulesAndPaths.label"), + Cell({ cell }) { + const getAbsoluteUrl = (host: string) => + host.startsWith("http://") || host.startsWith("https://") ? host : `https://${host}`; + return ( + <> + {cell.row.original.rulesAndPaths.map((ruleAndPaths) => ( +
+ + + {getAbsoluteUrl(ruleAndPaths.host)} + + + + {ruleAndPaths.paths.map((path) => ( +
+ {path.serviceName}:{path.port} +
+ ))} +
+ ))} + + ); + }, + }, + { + accessorKey: "creationTimestamp", + header: t("field.creationTimestamp.label"), + Cell: ({ row }) => dayjs(row.original.creationTimestamp).fromNow(false), + }, +]; + +export function IngressesTable(initialData: IngressesTableComponentProps) { + const tIngresses = useScopedI18n("kubernetes.ingresses"); + + const { data } = clientApi.kubernetes.ingresses.getIngresses.useQuery(undefined, { + initialData: initialData.initialIngresses, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + const table = useTranslatedMantineReactTable({ + data, + enableDensityToggle: false, + enableColumnActions: false, + enableColumnFilters: false, + enablePagination: false, + enableRowSelection: true, + positionToolbarAlertBanner: "top", + enableTableFooter: false, + enableBottomToolbar: false, + positionGlobalFilter: "right", + initialState: { density: "xs", showGlobalFilter: true }, + mantineSearchTextInputProps: { + placeholder: tIngresses("table.search", { count: data.length }), + style: { minWidth: 300 }, + autoFocus: true, + }, + + columns: createColumns(tIngresses), + }); + + return ; +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/page.tsx new file mode 100644 index 000000000..1cfe5aa03 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/ingresses/page.tsx @@ -0,0 +1,29 @@ +import { notFound } from "next/navigation"; +import { Stack, Title } from "@mantine/core"; + +import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/docker/env"; +import { getScopedI18n } from "@homarr/translation/server"; + +import { IngressesTable } from "~/app/[locale]/manage/tools/kubernetes/ingresses/ingresses-table"; +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; + +export default async function NamespacesPage() { + const session = await auth(); + if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { + notFound(); + } + + const ingresses = await api.kubernetes.ingresses.getIngresses(); + const tIngresses = await getScopedI18n("kubernetes.ingresses"); + return ( + <> + + + {tIngresses("label")} + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx new file mode 100644 index 000000000..9ef0e4a5b --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table.tsx @@ -0,0 +1,91 @@ +"use client"; + +import React from "react"; +import { Badge, rem } from "@mantine/core"; +import { IconCircleDashedCheck, IconHeartBroken } from "@tabler/icons-react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +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 type { KubernetesNamespace } from "@homarr/definitions"; +import type { ScopedTranslationFunction } from "@homarr/translation"; +import { useScopedI18n } from "@homarr/translation/client"; +import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; + +dayjs.extend(relativeTime); + +interface NamespacesTableComponentProps { + initialNamespaces: RouterOutputs["kubernetes"]["namespaces"]["getNamespaces"]; +} + +const createColumns = (t: ScopedTranslationFunction<"kubernetes.namespaces">): MRT_ColumnDef[] => [ + { + accessorKey: "status", + header: t("field.state.label"), + + Cell({ cell }) { + const checkIcon = ; + const downIcon = ; + + const badgeKubernetesNamespaceStatusColor = cell.row.original.status === "Active" ? "green" : "yellow"; + const badgeKubernetesNamespaceStatusIcon = cell.row.original.status === "Active" ? checkIcon : downIcon; + + return ( + + {cell.row.original.status} + + ); + }, + }, + { + accessorKey: "name", + header: t("field.name.label"), + enableClickToCopy: true, + }, + { + accessorKey: "creationTimestamp", + header: t("field.creationTimestamp.label"), + Cell: ({ row }) => dayjs(row.original.creationTimestamp).fromNow(false), + }, +]; + +export function NamespacesTable(initialData: NamespacesTableComponentProps) { + const tNamespaces = useScopedI18n("kubernetes.namespaces"); + + const { data } = clientApi.kubernetes.namespaces.getNamespaces.useQuery(undefined, { + initialData: initialData.initialNamespaces, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + const table = useTranslatedMantineReactTable({ + data, + enableDensityToggle: false, + enableColumnActions: false, + enableColumnFilters: false, + enablePagination: false, + enableRowSelection: true, + positionToolbarAlertBanner: "top", + enableTableFooter: false, + enableBottomToolbar: false, + positionGlobalFilter: "right", + initialState: { density: "xs", showGlobalFilter: true }, + mantineSearchTextInputProps: { + placeholder: tNamespaces("table.search", { count: data.length }), + style: { minWidth: 300 }, + autoFocus: true, + }, + + columns: createColumns(tNamespaces), + }); + + return ; +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/page.tsx new file mode 100644 index 000000000..14b48f757 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/namespaces/page.tsx @@ -0,0 +1,29 @@ +import { notFound } from "next/navigation"; +import { Stack, Title } from "@mantine/core"; + +import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/docker/env"; +import { getScopedI18n } from "@homarr/translation/server"; + +import { NamespacesTable } from "~/app/[locale]/manage/tools/kubernetes/namespaces/namespaces-table"; +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; + +export default async function NamespacesPage() { + const session = await auth(); + if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { + notFound(); + } + + const namespaces = await api.kubernetes.namespaces.getNamespaces(); + const tNamespaces = await getScopedI18n("kubernetes.namespaces"); + return ( + <> + + + {tNamespaces("label")} + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx new file mode 100644 index 000000000..b7b17d984 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/nodes-table.tsx @@ -0,0 +1,132 @@ +"use client"; + +import React from "react"; +import { Badge, rem, RingProgress, Text } from "@mantine/core"; +import { IconCircleDashedCheck, IconHeartBroken } from "@tabler/icons-react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +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 type { KubernetesNode } from "@homarr/definitions"; +import type { ScopedTranslationFunction } from "@homarr/translation"; +import { useScopedI18n } from "@homarr/translation/client"; +import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; + +dayjs.extend(relativeTime); + +interface NodesListComponentProps { + initialNodes: RouterOutputs["kubernetes"]["nodes"]["getNodes"]; +} + +const createColumns = (t: ScopedTranslationFunction<"kubernetes.nodes">): MRT_ColumnDef[] => [ + { + accessorKey: "status", + header: t("field.state.label"), + + Cell({ cell }) { + const checkIcon = ; + const downIcon = ; + + const badgeKubernetesNodeStatusColor = cell.row.original.status === "Ready" ? "green" : "red"; + const badgeKubernetesNodeStatusIcon = cell.row.original.status === "Ready" ? checkIcon : downIcon; + + return ( + + {cell.row.original.status} + + ); + }, + }, + { + accessorKey: "name", + header: t("field.name.label"), + enableClickToCopy: true, + }, + { + accessorKey: "allocatableCpuPercentage", + header: t("field.cpu.label"), + Cell({ cell }) { + return getRingProgress(cell.row.original.allocatableCpuPercentage); + }, + }, + { + accessorKey: "allocatableRamPercentage", + header: t("field.memory.label"), + Cell({ cell }) { + return getRingProgress(cell.row.original.allocatableRamPercentage); + }, + }, + { + accessorKey: "operatingSystem", + header: t("field.operatingSystem.label"), + }, + { + accessorKey: "podsCount", + header: t("field.pods.label"), + }, + { + accessorKey: "architecture", + header: t("field.architecture.label"), + }, + { + accessorKey: "kubernetesVersion", + header: t("field.kubernetesVersion.label"), + }, + { + accessorKey: "creationTimestamp", + header: t("field.creationTimestamp.label"), + Cell: ({ row }) => dayjs(row.original.creationTimestamp).fromNow(false), + }, +]; + +export function NodesTable(initialData: NodesListComponentProps) { + const tNodes = useScopedI18n("kubernetes.nodes"); + + const { data } = clientApi.kubernetes.nodes.getNodes.useQuery(undefined, { + initialData: initialData.initialNodes, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + const table = useTranslatedMantineReactTable({ + data, + enableDensityToggle: false, + enableColumnActions: false, + enableColumnFilters: false, + enablePagination: false, + enableRowSelection: true, + positionToolbarAlertBanner: "top", + enableTableFooter: false, + enableBottomToolbar: false, + positionGlobalFilter: "right", + initialState: { density: "xs", showGlobalFilter: true }, + mantineSearchTextInputProps: { + placeholder: tNodes("table.search", { count: data.length }), + style: { minWidth: 300 }, + autoFocus: true, + }, + columns: createColumns(tNodes), + }); + + return ; +} + +function getRingProgress(value: number) { + return ( + + {value}% + + } + /> + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/page.tsx new file mode 100644 index 000000000..20e59f80f --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/nodes/page.tsx @@ -0,0 +1,29 @@ +import { notFound } from "next/navigation"; +import { Stack, Title } from "@mantine/core"; + +import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/docker/env"; +import { getScopedI18n } from "@homarr/translation/server"; + +import { NodesTable } from "~/app/[locale]/manage/tools/kubernetes/nodes/nodes-table"; +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; + +export default async function NodesPage() { + const session = await auth(); + if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { + notFound(); + } + + const nodes = await api.kubernetes.nodes.getNodes(); + const tNodes = await getScopedI18n("kubernetes.nodes"); + return ( + <> + + + {tNodes("label")} + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/page.tsx new file mode 100644 index 000000000..ae9d8fe4c --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/page.tsx @@ -0,0 +1,21 @@ +import { notFound } from "next/navigation"; + +import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/docker/env"; + +import { ClusterDashboard } from "~/app/[locale]/manage/tools/kubernetes/cluster-dashboard/cluster-dashboard"; +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; + +export default async function KubernetesPage() { + const session = await auth(); + if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { + notFound(); + } + + return ( + <> + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/page.tsx new file mode 100644 index 000000000..b261bf4e2 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/page.tsx @@ -0,0 +1,29 @@ +import { notFound } from "next/navigation"; +import { Stack, Title } from "@mantine/core"; + +import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/docker/env"; +import { getScopedI18n } from "@homarr/translation/server"; + +import { PodsTable } from "~/app/[locale]/manage/tools/kubernetes/pods/pods-table"; +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; + +export default async function PodsPage() { + const session = await auth(); + if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { + notFound(); + } + + const pods = await api.kubernetes.pods.getPods(); + const tPods = await getScopedI18n("kubernetes.pods"); + return ( + <> + + + {tPods("label")} + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx new file mode 100644 index 000000000..3a1e10820 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/pods/pods-table.tsx @@ -0,0 +1,82 @@ +"use client"; + +import React from "react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +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 type { KubernetesPod } from "@homarr/definitions"; +import type { ScopedTranslationFunction } from "@homarr/translation"; +import { useScopedI18n } from "@homarr/translation/client"; +import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; + +dayjs.extend(relativeTime); + +interface PodsTableComponentProps { + initialPods: RouterOutputs["kubernetes"]["pods"]["getPods"]; +} + +const createColumns = (t: ScopedTranslationFunction<"kubernetes.pods">): MRT_ColumnDef[] => [ + { + accessorKey: "name", + header: t("field.name.label"), + }, + { + accessorKey: "namespace", + header: t("field.namespace.label"), + }, + { + accessorKey: "image", + header: t("field.image.label"), + }, + { + accessorKey: "applicationType", + header: t("field.applicationType.label"), + }, + { + accessorKey: "status", + header: t("field.status.label"), + }, + { + accessorKey: "creationTimestamp", + header: t("field.creationTimestamp.label"), + Cell: ({ row }) => dayjs(row.original.creationTimestamp).fromNow(false), + }, +]; + +export function PodsTable(initialData: PodsTableComponentProps) { + const tPods = useScopedI18n("kubernetes.pods"); + + const { data } = clientApi.kubernetes.pods.getPods.useQuery(undefined, { + initialData: initialData.initialPods, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + const table = useTranslatedMantineReactTable({ + data, + enableDensityToggle: false, + enableColumnActions: false, + enableColumnFilters: false, + enablePagination: false, + enableRowSelection: true, + positionToolbarAlertBanner: "top", + enableTableFooter: false, + enableBottomToolbar: false, + positionGlobalFilter: "right", + initialState: { density: "xs", showGlobalFilter: true, expanded: true }, + mantineSearchTextInputProps: { + placeholder: tPods("table.search", { count: data.length }), + style: { minWidth: 300 }, + autoFocus: true, + }, + enableGrouping: true, + columns: createColumns(tPods), + }); + + return ; +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/page.tsx new file mode 100644 index 000000000..b52b1ea41 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/page.tsx @@ -0,0 +1,29 @@ +import { notFound } from "next/navigation"; +import { Stack, Title } from "@mantine/core"; + +import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/docker/env"; +import { getScopedI18n } from "@homarr/translation/server"; + +import { SecretsTable } from "~/app/[locale]/manage/tools/kubernetes/secrets/secrets-table"; +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; + +export default async function SecretsPage() { + const session = await auth(); + if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { + notFound(); + } + + const secrets = await api.kubernetes.secrets.getSecrets(); + const tSecrets = await getScopedI18n("kubernetes.secrets"); + return ( + <> + + + {tSecrets("label")} + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx new file mode 100644 index 000000000..3c24ad0f1 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/secrets/secrets-table.tsx @@ -0,0 +1,77 @@ +"use client"; + +import React from "react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +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 type { KubernetesSecret } from "@homarr/definitions"; +import type { ScopedTranslationFunction } from "@homarr/translation"; +import { useScopedI18n } from "@homarr/translation/client"; +import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; + +dayjs.extend(relativeTime); + +interface SecretsTableComponentProps { + initialSecrets: RouterOutputs["kubernetes"]["secrets"]["getSecrets"]; +} + +const createColumns = (t: ScopedTranslationFunction<"kubernetes.secrets">): MRT_ColumnDef[] => [ + { + accessorKey: "name", + header: t("field.name.label"), + enableClickToCopy: true, + }, + { + accessorKey: "namespace", + header: t("field.namespace.label"), + enableClickToCopy: true, + }, + { + accessorKey: "type", + header: t("field.type.label"), + enableClickToCopy: true, + }, + { + accessorKey: "creationTimestamp", + header: t("field.creationTimestamp.label"), + Cell: ({ row }) => dayjs(row.original.creationTimestamp).fromNow(false), + }, +]; + +export function SecretsTable(initialData: SecretsTableComponentProps) { + const tSecrets = useScopedI18n("kubernetes.secrets"); + + const { data } = clientApi.kubernetes.secrets.getSecrets.useQuery(undefined, { + initialData: initialData.initialSecrets, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + const table = useTranslatedMantineReactTable({ + data, + enableDensityToggle: false, + enableColumnActions: false, + enableColumnFilters: false, + enablePagination: false, + enableRowSelection: true, + positionToolbarAlertBanner: "top", + enableTableFooter: false, + enableBottomToolbar: false, + positionGlobalFilter: "right", + initialState: { density: "xs", showGlobalFilter: true }, + mantineSearchTextInputProps: { + placeholder: tSecrets("table.search", { count: data.length }), + style: { minWidth: 300 }, + autoFocus: true, + }, + + columns: createColumns(tSecrets), + }); + + return ; +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/page.tsx new file mode 100644 index 000000000..d50467aa8 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/page.tsx @@ -0,0 +1,29 @@ +import { notFound } from "next/navigation"; +import { Stack, Title } from "@mantine/core"; + +import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/docker/env"; +import { getScopedI18n } from "@homarr/translation/server"; + +import { ServicesTable } from "~/app/[locale]/manage/tools/kubernetes/services/services-table"; +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; + +export default async function ServicesPage() { + const session = await auth(); + if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { + notFound(); + } + + const services = await api.kubernetes.services.getServices(); + const tServices = await getScopedI18n("kubernetes.services"); + return ( + <> + + + {tServices("label")} + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx new file mode 100644 index 000000000..7e13a630a --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/services/services-table.tsx @@ -0,0 +1,95 @@ +"use client"; + +import React from "react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +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 { createId } from "@homarr/db/client"; +import type { KubernetesService } from "@homarr/definitions"; +import type { ScopedTranslationFunction } from "@homarr/translation"; +import { useScopedI18n } from "@homarr/translation/client"; +import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; + +dayjs.extend(relativeTime); + +interface ServicesTableComponentProps { + initialServices: RouterOutputs["kubernetes"]["services"]["getServices"]; +} + +const createColumns = (t: ScopedTranslationFunction<"kubernetes.services">): MRT_ColumnDef[] => [ + { + accessorKey: "name", + header: t("field.name.label"), + enableClickToCopy: true, + }, + { + accessorKey: "namespace", + header: t("field.namespace.label"), + enableClickToCopy: true, + }, + { + accessorKey: "type", + header: t("field.type.label"), + }, + { + accessorKey: "ports", + header: t("field.ports.label"), + Cell({ cell }) { + return cell.row.original.ports?.map((port) =>
{port}
); + }, + }, + { + accessorKey: "targetPorts", + header: t("field.targetPorts.label"), + Cell({ cell }) { + return cell.row.original.targetPorts?.map((targetPort) =>
{targetPort}
); + }, + }, + { + accessorKey: "clusterIP", + header: t("field.clusterIP.label"), + enableClickToCopy: true, + }, + { + accessorKey: "creationTimestamp", + header: t("field.creationTimestamp.label"), + Cell: ({ row }) => dayjs(row.original.creationTimestamp).fromNow(false), + }, +]; + +export function ServicesTable(initialData: ServicesTableComponentProps) { + const tServices = useScopedI18n("kubernetes.services"); + + const { data } = clientApi.kubernetes.services.getServices.useQuery(undefined, { + initialData: initialData.initialServices, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + const table = useTranslatedMantineReactTable({ + data, + enableDensityToggle: false, + enableColumnActions: false, + enableColumnFilters: false, + enablePagination: false, + enableRowSelection: true, + positionToolbarAlertBanner: "top", + enableTableFooter: false, + enableBottomToolbar: false, + positionGlobalFilter: "right", + initialState: { density: "xs", showGlobalFilter: true }, + mantineSearchTextInputProps: { + placeholder: tServices("table.search", { count: data.length }), + style: { minWidth: 300 }, + autoFocus: true, + }, + columns: createColumns(tServices), + }); + + return ; +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/page.tsx new file mode 100644 index 000000000..301371dab --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/page.tsx @@ -0,0 +1,29 @@ +import { notFound } from "next/navigation"; +import { Stack, Title } from "@mantine/core"; + +import { api } from "@homarr/api/server"; +import { auth } from "@homarr/auth/next"; +import { env } from "@homarr/docker/env"; +import { getScopedI18n } from "@homarr/translation/server"; + +import { VolumesTable } from "~/app/[locale]/manage/tools/kubernetes/volumes/volumes-table"; +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; + +export default async function VolumesPage() { + const session = await auth(); + if (!(session?.user.permissions.includes("admin") && env.ENABLE_KUBERNETES)) { + notFound(); + } + + const volumes = await api.kubernetes.volumes.getVolumes(); + const tVolumes = await getScopedI18n("kubernetes.volumes"); + return ( + <> + + + {tVolumes("label")} + + + + ); +} diff --git a/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx new file mode 100644 index 000000000..be217d314 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/tools/kubernetes/volumes/volumes-table.tsx @@ -0,0 +1,101 @@ +"use client"; + +import React from "react"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +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 type { KubernetesVolume } from "@homarr/definitions"; +import type { ScopedTranslationFunction } from "@homarr/translation"; +import { useScopedI18n } from "@homarr/translation/client"; +import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; + +dayjs.extend(relativeTime); + +interface VolumesTableComponentProps { + initialVolumes: RouterOutputs["kubernetes"]["volumes"]["getVolumes"]; +} + +const createColumns = (t: ScopedTranslationFunction<"kubernetes.volumes">): MRT_ColumnDef[] => [ + { + accessorKey: "status", + header: t("field.status.label"), + }, + { + accessorKey: "name", + header: t("field.name.label"), + enableClickToCopy: true, + }, + { + accessorKey: "namespace", + header: t("field.namespace.label"), + enableClickToCopy: true, + }, + { + accessorKey: "storage", + header: t("field.storage.label"), + }, + { + accessorKey: "storageClassName", + header: t("field.storageClassName.label"), + enableClickToCopy: true, + }, + { + accessorKey: "volumeMode", + header: t("field.volumeMode.label"), + }, + { + accessorKey: "volumeName", + header: t("field.volumeName.label"), + enableClickToCopy: true, + }, + { + accessorKey: "accessModes", + header: t("field.accessModes.label"), + Cell({ cell }) { + return cell.row.original.accessModes.map((accessMode) =>
{accessMode}
); + }, + }, + { + accessorKey: "creationTimestamp", + header: t("field.creationTimestamp.label"), + Cell: ({ row }) => dayjs(row.original.creationTimestamp).fromNow(false), + }, +]; + +export function VolumesTable(initialData: VolumesTableComponentProps) { + const tVolumes = useScopedI18n("kubernetes.volumes"); + + const { data } = clientApi.kubernetes.volumes.getVolumes.useQuery(undefined, { + initialData: initialData.initialVolumes, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + const table = useTranslatedMantineReactTable({ + data, + enableDensityToggle: false, + enableColumnActions: false, + enableColumnFilters: false, + enablePagination: false, + enableRowSelection: true, + positionToolbarAlertBanner: "top", + enableTableFooter: false, + enableBottomToolbar: false, + positionGlobalFilter: "right", + initialState: { density: "xs", showGlobalFilter: true }, + mantineSearchTextInputProps: { + placeholder: tVolumes("table.search", { count: data.length }), + style: { minWidth: 300 }, + autoFocus: true, + }, + + columns: createColumns(tVolumes), + }); + + return ; +} diff --git a/development/development.docker-compose.yml b/development/development.docker-compose.yml index 9e8557808..0ee173260 100644 --- a/development/development.docker-compose.yml +++ b/development/development.docker-compose.yml @@ -11,4 +11,20 @@ services: container_name: redis image: redis ports: - - "6379:6379" \ No newline at end of file + - "6379:6379" + + mysql: + container_name: mysql + image: mysql:8.0 + ports: + - "3306:3306" + environment: + MYSQL_ROOT_PASSWORD: homarr + MYSQL_DATABASE: homarrdb + MYSQL_USER: homarr + MYSQL_PASSWORD: homarr + volumes: + - mysql_data:/var/lib/mysql + +volumes: + mysql_data: \ No newline at end of file diff --git a/packages/api/package.json b/packages/api/package.json index 7081da960..31667a3a4 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -40,6 +40,7 @@ "@homarr/request-handler": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", + "@kubernetes/client-node": "^1.0.0", "@trpc/client": "next", "@trpc/react-query": "next", "@trpc/server": "next", diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts new file mode 100644 index 000000000..61512165d --- /dev/null +++ b/packages/api/src/env.ts @@ -0,0 +1,15 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +import { shouldSkipEnvValidation } from "@homarr/common/env-validation"; + +export const env = createEnv({ + server: { + KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(), + }, + runtimeEnv: { + KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME, + }, + skipValidation: shouldSkipEnvValidation(), + emptyStringAsUndefined: true, +}); diff --git a/packages/api/src/middlewares/docker.ts b/packages/api/src/middlewares/docker.ts new file mode 100644 index 000000000..5a317b95a --- /dev/null +++ b/packages/api/src/middlewares/docker.ts @@ -0,0 +1,17 @@ +import { TRPCError } from "@trpc/server"; + +import { env } from "@homarr/docker/env"; + +import { publicProcedure } from "../trpc"; + +export const dockerMiddleware = () => { + return publicProcedure.use(async ({ next }) => { + if (env.ENABLE_DOCKER) { + return await next(); + } + throw new TRPCError({ + code: "NOT_FOUND", + message: "Docker route is not available", + }); + }); +}; diff --git a/packages/api/src/middlewares/kubernetes.ts b/packages/api/src/middlewares/kubernetes.ts new file mode 100644 index 000000000..8cefc0c35 --- /dev/null +++ b/packages/api/src/middlewares/kubernetes.ts @@ -0,0 +1,17 @@ +import { TRPCError } from "@trpc/server"; + +import { env } from "@homarr/docker/env"; + +import { publicProcedure } from "../trpc"; + +export const kubernetesMiddleware = () => { + return publicProcedure.use(async ({ next }) => { + if (env.ENABLE_KUBERNETES) { + return await next(); + } + throw new TRPCError({ + code: "NOT_FOUND", + message: "Kubernetes route is not available", + }); + }); +}; diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 1a0a225df..7aec5f2ac 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -10,6 +10,7 @@ import { iconsRouter } from "./router/icons"; import { importRouter } from "./router/import/import-router"; import { integrationRouter } from "./router/integration/integration-router"; import { inviteRouter } from "./router/invite"; +import { kubernetesRouter } from "./router/kubernetes/router/kubernetes-router"; import { locationRouter } from "./router/location"; import { logRouter } from "./router/log"; import { mediaRouter } from "./router/medias/media-router"; @@ -39,6 +40,7 @@ export const appRouter = createTRPCRouter({ onboard: onboardRouter, home: homeRouter, docker: dockerRouter, + kubernetes: kubernetesRouter, serverSettings: serverSettingsRouter, cronJobs: cronJobsRouter, apiKeys: apiKeysRouter, diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index 05841e577..8b16de81c 100644 --- a/packages/api/src/router/docker/docker-router.ts +++ b/packages/api/src/router/docker/docker-router.ts @@ -8,6 +8,7 @@ import type { Container, ContainerInfo, ContainerState, Docker, Port } from "@ho import { logger } from "@homarr/log"; import { createCacheChannel } from "@homarr/redis"; +import { dockerMiddleware } from "../../middlewares/docker"; import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc"; const dockerCache = createCacheChannel<{ @@ -15,72 +16,79 @@ const dockerCache = createCacheChannel<{ }>("docker-containers", 5 * 60 * 1000); export const dockerRouter = createTRPCRouter({ - getContainers: permissionRequiredProcedure.requiresPermission("admin").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, - })), + getContainers: permissionRequiredProcedure + .requiresPermission("admin") + .unstable_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 { data, timestamp } = result; + ).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, + }); + } - return { - containers: sanitizeContainers(data.containers), - timestamp, - }; - }), - invalidate: permissionRequiredProcedure.requiresPermission("admin").mutation(async () => { - await dockerCache.invalidateAsync(); - return; - }), + const { data, timestamp } = result; + + return { + containers: sanitizeContainers(data.containers), + timestamp, + }; + }), + invalidate: permissionRequiredProcedure + .requiresPermission("admin") + .unstable_concat(dockerMiddleware()) + .mutation(async () => { + await dockerCache.invalidateAsync(); + return; + }), startAll: permissionRequiredProcedure .requiresPermission("admin") + .unstable_concat(dockerMiddleware()) .input(z.object({ ids: z.array(z.string()) })) .mutation(async ({ input }) => { await Promise.allSettled( @@ -94,6 +102,7 @@ export const dockerRouter = createTRPCRouter({ }), stopAll: permissionRequiredProcedure .requiresPermission("admin") + .unstable_concat(dockerMiddleware()) .input(z.object({ ids: z.array(z.string()) })) .mutation(async ({ input }) => { await Promise.allSettled( @@ -107,6 +116,7 @@ export const dockerRouter = createTRPCRouter({ }), restartAll: permissionRequiredProcedure .requiresPermission("admin") + .unstable_concat(dockerMiddleware()) .input(z.object({ ids: z.array(z.string()) })) .mutation(async ({ input }) => { await Promise.allSettled( @@ -120,6 +130,7 @@ export const dockerRouter = createTRPCRouter({ }), removeAll: permissionRequiredProcedure .requiresPermission("admin") + .unstable_concat(dockerMiddleware()) .input(z.object({ ids: z.array(z.string()) })) .mutation(async ({ input }) => { await Promise.allSettled( diff --git a/packages/api/src/router/kubernetes/kubernetes-client.ts b/packages/api/src/router/kubernetes/kubernetes-client.ts new file mode 100644 index 000000000..c0cb907aa --- /dev/null +++ b/packages/api/src/router/kubernetes/kubernetes-client.ts @@ -0,0 +1,73 @@ +import * as fs from "fs"; +import { CoreV1Api, KubeConfig, Metrics, NetworkingV1Api, VersionApi } from "@kubernetes/client-node"; + +import { env } from "../../env"; + +export class KubernetesClient { + private static instance: KubernetesClient | null = null; + public kubeConfig: KubeConfig; + public coreApi: CoreV1Api; + public networkingApi: NetworkingV1Api; + public metricsApi: Metrics; + public versionApi: VersionApi; + + private constructor() { + this.kubeConfig = new KubeConfig(); + + if (process.env.NODE_ENV === "development") { + this.kubeConfig.loadFromDefault(); + } else { + this.kubeConfig.loadFromCluster(); + + const currentCluster = this.kubeConfig.getCurrentCluster(); + if (!currentCluster) throw new Error("No cluster configuration found"); + + const token = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8"); + const caData = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", "utf8"); + + const clusterWithCA = { + ...currentCluster, + name: `${currentCluster.name}-service-account`, + caData, + }; + + const serviceAccountUser = { + name: env.KUBERNETES_SERVICE_ACCOUNT_NAME ?? "default-sa", + token, + }; + + this.kubeConfig.clusters = []; + this.kubeConfig.users = []; + + this.kubeConfig.addCluster(clusterWithCA); + this.kubeConfig.addUser(serviceAccountUser); + + const currentContext = this.kubeConfig.getCurrentContext(); + const originalContext = this.kubeConfig.getContextObject(currentContext); + if (!originalContext) throw new Error("No context found"); + + const updatedContext = { + ...originalContext, + name: `${originalContext.name}-service-account`, + cluster: clusterWithCA.name, + user: serviceAccountUser.name, + }; + + this.kubeConfig.contexts = []; + this.kubeConfig.addContext(updatedContext); + this.kubeConfig.setCurrentContext(updatedContext.name); + } + + this.coreApi = this.kubeConfig.makeApiClient(CoreV1Api); + this.networkingApi = this.kubeConfig.makeApiClient(NetworkingV1Api); + this.metricsApi = new Metrics(this.kubeConfig); + this.versionApi = this.kubeConfig.makeApiClient(VersionApi); + } + + public static getInstance(): KubernetesClient { + if (!KubernetesClient.instance) { + KubernetesClient.instance = new KubernetesClient(); + } + return KubernetesClient.instance; + } +} diff --git a/packages/api/src/router/kubernetes/resource-parser/cpu-resource-parser.ts b/packages/api/src/router/kubernetes/resource-parser/cpu-resource-parser.ts new file mode 100644 index 000000000..d36017864 --- /dev/null +++ b/packages/api/src/router/kubernetes/resource-parser/cpu-resource-parser.ts @@ -0,0 +1,41 @@ +import type { ResourceParser } from "./resource-parser"; + +export class CpuResourceParser implements ResourceParser { + private readonly billionthsCore = 1_000_000_000; + private readonly millionthsCore = 1_000_000; + private readonly MiliCore = 1_000; + private readonly ThousandCore = 1_000; + + parse(value: string): number { + if (!value.length) { + return NaN; + } + + value = value.replace(/,/g, "").trim(); + + const [, numericValue, unit = ""] = /^([0-9.]+)\s*([a-zA-Z]*)$/.exec(value) ?? []; + + if (numericValue === undefined) { + return NaN; + } + + const parsedValue = parseFloat(numericValue); + + if (isNaN(parsedValue)) { + return NaN; + } + + switch (unit.toLowerCase()) { + case "n": // nano-cores (billionths of a core) + return parsedValue / this.billionthsCore; // 1 NanoCPU = 1/1,000,000,000 cores + case "u": // micro-cores (millionths of a core) + return parsedValue / this.millionthsCore; // 1 MicroCPU = 1/1,000,000 cores + case "m": // milli-cores + return parsedValue / this.MiliCore; // 1 milli-core = 1/1000 cores + case "k": // thousands of cores + return parsedValue * this.ThousandCore; // 1 thousand-core = 1000 cores + default: // cores (no unit) + return parsedValue; + } + } +} diff --git a/packages/api/src/router/kubernetes/resource-parser/memory-resource-parser.ts b/packages/api/src/router/kubernetes/resource-parser/memory-resource-parser.ts new file mode 100644 index 000000000..50a98b8ef --- /dev/null +++ b/packages/api/src/router/kubernetes/resource-parser/memory-resource-parser.ts @@ -0,0 +1,69 @@ +import type { ResourceParser } from "./resource-parser"; + +export class MemoryResourceParser implements ResourceParser { + private readonly binaryMultipliers: Record = { + ki: 1024, + mi: 1024 ** 2, + gi: 1024 ** 3, + ti: 1024 ** 4, + pi: 1024 ** 5, + } as const; + + private readonly decimalMultipliers: Record = { + k: 1000, + m: 1000 ** 2, + g: 1000 ** 3, + t: 1000 ** 4, + p: 1000 ** 5, + } as const; + + parse(value: string): number { + if (!value.length) { + return NaN; + } + + value = value.replace(/,/g, "").trim(); + + const [, numericValue, unit = ""] = /^([0-9.]+)\s*([a-zA-Z]*)$/.exec(value) ?? []; + + if (!numericValue) { + return NaN; + } + + const parsedValue = parseFloat(numericValue); + + if (isNaN(parsedValue)) { + return NaN; + } + + const unitLower = unit.toLowerCase(); + + // Handle binary units (Ki, Mi, Gi, etc.) + if (unitLower in this.binaryMultipliers) { + const multiplier = this.binaryMultipliers[unitLower]; + const giMultiplier = this.binaryMultipliers.gi; + + if (multiplier !== undefined && giMultiplier !== undefined) { + return (parsedValue * multiplier) / giMultiplier; + } + } + + // Handle decimal units (K, M, G, etc.) + if (unitLower in this.decimalMultipliers) { + const multiplier = this.decimalMultipliers[unitLower]; + const giMultiplier = this.binaryMultipliers.gi; + + if (multiplier !== undefined && giMultiplier !== undefined) { + return (parsedValue * multiplier) / giMultiplier; + } + } + + // No unit or unrecognized unit, assume bytes and convert to GiB + const giMultiplier = this.binaryMultipliers.gi; + if (giMultiplier !== undefined) { + return parsedValue / giMultiplier; + } + + return NaN; // Return NaN if giMultiplier is undefined + } +} diff --git a/packages/api/src/router/kubernetes/resource-parser/resource-parser.ts b/packages/api/src/router/kubernetes/resource-parser/resource-parser.ts new file mode 100644 index 000000000..23b652af8 --- /dev/null +++ b/packages/api/src/router/kubernetes/resource-parser/resource-parser.ts @@ -0,0 +1,3 @@ +export interface ResourceParser { + parse(value: string): number; +} diff --git a/packages/api/src/router/kubernetes/router/cluster.ts b/packages/api/src/router/kubernetes/router/cluster.ts new file mode 100644 index 000000000..8b44e5dcd --- /dev/null +++ b/packages/api/src/router/kubernetes/router/cluster.ts @@ -0,0 +1,196 @@ +import type { V1NodeList, VersionInfo } from "@kubernetes/client-node"; +import { TRPCError } from "@trpc/server"; + +import type { ClusterResourceCount, KubernetesCluster } from "@homarr/definitions"; +import { logger } from "@homarr/log"; + +import { kubernetesMiddleware } from "../../../middlewares/kubernetes"; +import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc"; +import { KubernetesClient } from "../kubernetes-client"; +import { CpuResourceParser } from "../resource-parser/cpu-resource-parser"; +import { MemoryResourceParser } from "../resource-parser/memory-resource-parser"; + +export const clusterRouter = createTRPCRouter({ + getCluster: permissionRequiredProcedure + .requiresPermission("admin") + .unstable_concat(kubernetesMiddleware()) + .query(async (): Promise => { + const { coreApi, metricsApi, versionApi, kubeConfig } = KubernetesClient.getInstance(); + + try { + const versionInfo = await versionApi.getCode(); + const nodes = await coreApi.listNode(); + const nodeMetricsClient = await metricsApi.getNodeMetrics(); + const listPodForAllNamespaces = await coreApi.listPodForAllNamespaces(); + + let totalCPUCapacity = 0; + let totalCPUAllocatable = 0; + let totalCPUUsage = 0; + + let totalMemoryCapacity = 0; + let totalMemoryAllocatable = 0; + let totalMemoryUsage = 0; + + let totalCapacityPods = 0; + const cpuResourceParser = new CpuResourceParser(); + const memoryResourceParser = new MemoryResourceParser(); + + nodes.items.forEach((node) => { + totalCapacityPods += Number(node.status?.capacity?.pods); + + const cpuCapacity = cpuResourceParser.parse(node.status?.capacity?.cpu ?? "0"); + const cpuAllocatable = cpuResourceParser.parse(node.status?.allocatable?.cpu ?? "0"); + totalCPUCapacity += cpuCapacity; + totalCPUAllocatable += cpuAllocatable; + + const memoryCapacity = memoryResourceParser.parse(node.status?.capacity?.memory ?? "0"); + const memoryAllocatable = memoryResourceParser.parse(node.status?.allocatable?.memory ?? "0"); + totalMemoryCapacity += memoryCapacity; + totalMemoryAllocatable += memoryAllocatable; + + const nodeName = node.metadata?.name; + const nodeMetric = nodeMetricsClient.items.find((metric) => metric.metadata.name === nodeName); + if (nodeMetric) { + const cpuUsage = cpuResourceParser.parse(nodeMetric.usage.cpu); + totalCPUUsage += cpuUsage; + + const memoryUsage = memoryResourceParser.parse(nodeMetric.usage.memory); + totalMemoryUsage += memoryUsage; + } + }); + + const reservedCPU = totalCPUCapacity - totalCPUAllocatable; + const reservedMemory = totalMemoryCapacity - totalMemoryAllocatable; + + const reservedCPUPercentage = (reservedCPU / totalCPUCapacity) * 100; + const reservedMemoryPercentage = (reservedMemory / totalMemoryCapacity) * 100; + + const usagePercentageAllocatable = (totalCPUUsage / totalCPUAllocatable) * 100; + const usagePercentageMemoryAllocatable = (totalMemoryUsage / totalMemoryAllocatable) * 100; + + const usedPodsPercentage = (listPodForAllNamespaces.items.length / totalCapacityPods) * 100; + + return { + name: kubeConfig.getCurrentContext(), + providers: getProviders(versionInfo, nodes), + kubernetesVersion: versionInfo.gitVersion, + architecture: versionInfo.platform, + nodeCount: nodes.items.length, + capacity: [ + { + type: "CPU", + resourcesStats: [ + { + percentageValue: Number(reservedCPUPercentage.toFixed(2)), + type: "Reserved", + capacityUnit: "Cores", + usedValue: Number(reservedCPU.toFixed(2)), + maxUsedValue: Number(totalCPUCapacity.toFixed(2)), + }, + { + percentageValue: Number(usagePercentageAllocatable.toFixed(2)), + type: "Used", + capacityUnit: "Cores", + usedValue: Number(totalCPUUsage.toFixed(2)), + maxUsedValue: Number(totalCPUAllocatable.toFixed(2)), + }, + ], + }, + { + type: "Memory", + resourcesStats: [ + { + percentageValue: Number(reservedMemoryPercentage.toFixed(2)), + type: "Reserved", + capacityUnit: "GiB", + usedValue: Number(reservedMemory.toFixed(2)), + maxUsedValue: Number(totalMemoryCapacity.toFixed(2)), + }, + { + percentageValue: Number(usagePercentageMemoryAllocatable.toFixed(2)), + type: "Used", + capacityUnit: "GiB", + usedValue: Number(totalMemoryUsage.toFixed(2)), + maxUsedValue: Number(totalMemoryAllocatable.toFixed(2)), + }, + ], + }, + { + type: "Pods", + resourcesStats: [ + { + percentageValue: Number(usedPodsPercentage.toFixed(2)), + type: "Used", + usedValue: listPodForAllNamespaces.items.length, + maxUsedValue: totalCapacityPods, + }, + ], + }, + ], + }; + } catch (error) { + logger.error("Unable to retrieve cluster", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "An error occurred while fetching Kubernetes cluster", + cause: error, + }); + } + }), + getClusterResourceCounts: permissionRequiredProcedure + .requiresPermission("admin") + .query(async (): Promise => { + const { coreApi, networkingApi } = KubernetesClient.getInstance(); + + try { + const [pods, ingresses, services, configMaps, namespaces, nodes, secrets, volumes] = await Promise.all([ + coreApi.listPodForAllNamespaces(), + networkingApi.listIngressForAllNamespaces(), + coreApi.listServiceForAllNamespaces(), + coreApi.listConfigMapForAllNamespaces(), + coreApi.listNamespace(), + coreApi.listNode(), + coreApi.listSecretForAllNamespaces(), + coreApi.listPersistentVolumeClaimForAllNamespaces(), + ]); + + return [ + { label: "nodes", count: nodes.items.length }, + { label: "namespaces", count: namespaces.items.length }, + { label: "ingresses", count: ingresses.items.length }, + { label: "services", count: services.items.length }, + { label: "pods", count: pods.items.length }, + { label: "secrets", count: secrets.items.length }, + { label: "configmaps", count: configMaps.items.length }, + { label: "volumes", count: volumes.items.length }, + ]; + } catch (error) { + logger.error("Unable to retrieve cluster resource counts", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "An error occurred while fetching Kubernetes resources count", + cause: error, + }); + } + }), +}); + +function getProviders(versionInfo: VersionInfo, nodes: V1NodeList) { + const providers = new Set(); + + if (versionInfo.gitVersion.includes("k3s")) providers.add("k3s"); + if (versionInfo.gitVersion.includes("gke")) providers.add("GKE"); + if (versionInfo.gitVersion.includes("eks")) providers.add("EKS"); + if (versionInfo.gitVersion.includes("aks")) providers.add("AKS"); + + nodes.items.forEach((node) => { + const nodeProviderLabel = + node.metadata?.labels?.["node.kubernetes.io/instance-type"] ?? node.metadata?.labels?.provider ?? ""; + if (nodeProviderLabel.includes("aws")) providers.add("EKS"); + if (nodeProviderLabel.includes("azure")) providers.add("AKS"); + if (nodeProviderLabel.includes("gce")) providers.add("GKE"); + if (nodeProviderLabel.includes("k3s")) providers.add("k3s"); + }); + + return Array.from(providers).join(", "); +} diff --git a/packages/api/src/router/kubernetes/router/configMaps.ts b/packages/api/src/router/kubernetes/router/configMaps.ts new file mode 100644 index 000000000..93aa1eb36 --- /dev/null +++ b/packages/api/src/router/kubernetes/router/configMaps.ts @@ -0,0 +1,36 @@ +import { TRPCError } from "@trpc/server"; + +import type { KubernetesBaseResource } from "@homarr/definitions"; +import { logger } from "@homarr/log"; + +import { kubernetesMiddleware } from "../../../middlewares/kubernetes"; +import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc"; +import { KubernetesClient } from "../kubernetes-client"; + +export const configMapsRouter = createTRPCRouter({ + getConfigMaps: permissionRequiredProcedure + .requiresPermission("admin") + .unstable_concat(kubernetesMiddleware()) + .query(async (): Promise => { + const { coreApi } = KubernetesClient.getInstance(); + + try { + const configMaps = await coreApi.listConfigMapForAllNamespaces(); + + return configMaps.items.map((configMap) => { + return { + name: configMap.metadata?.name ?? "unknown", + namespace: configMap.metadata?.namespace ?? "unknown", + creationTimestamp: configMap.metadata?.creationTimestamp, + }; + }); + } catch (error) { + logger.error("Unable to retrieve configMaps", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "An error occurred while fetching Kubernetes ConfigMaps", + cause: error, + }); + } + }), +}); diff --git a/packages/api/src/router/kubernetes/router/ingresses.ts b/packages/api/src/router/kubernetes/router/ingresses.ts new file mode 100644 index 000000000..e0bea4932 --- /dev/null +++ b/packages/api/src/router/kubernetes/router/ingresses.ts @@ -0,0 +1,54 @@ +import type { V1HTTPIngressPath, V1Ingress, V1IngressRule } from "@kubernetes/client-node"; +import { TRPCError } from "@trpc/server"; + +import type { KubernetesIngress, KubernetesIngressPath, KubernetesIngressRuleAndPath } from "@homarr/definitions"; +import { logger } from "@homarr/log"; + +import { kubernetesMiddleware } from "../../../middlewares/kubernetes"; +import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc"; +import { KubernetesClient } from "../kubernetes-client"; + +export const ingressesRouter = createTRPCRouter({ + getIngresses: permissionRequiredProcedure + .requiresPermission("admin") + .unstable_concat(kubernetesMiddleware()) + .query(async (): Promise => { + const { networkingApi } = KubernetesClient.getInstance(); + try { + const ingresses = await networkingApi.listIngressForAllNamespaces(); + + const mapIngress = (ingress: V1Ingress): KubernetesIngress => { + return { + name: ingress.metadata?.name ?? "", + namespace: ingress.metadata?.namespace ?? "", + className: ingress.spec?.ingressClassName ?? "", + rulesAndPaths: getIngressRulesAndPaths(ingress.spec?.rules ?? []), + creationTimestamp: ingress.metadata?.creationTimestamp, + }; + }; + + const getIngressRulesAndPaths = (rules: V1IngressRule[] = []): KubernetesIngressRuleAndPath[] => { + return rules.map((rule) => ({ + host: rule.host ?? "", + paths: getPaths(rule.http?.paths ?? []), + })); + }; + + const getPaths = (paths: V1HTTPIngressPath[] = []): KubernetesIngressPath[] => { + return paths.map((path) => ({ + serviceName: path.backend.service?.name ?? "", + port: path.backend.service?.port?.number ?? 0, + })); + }; + + return ingresses.items.map(mapIngress); + } catch (error) { + logger.error("Unable to retrieve ingresses", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "An error occurred while fetching Kubernetes ingresses", + cause: error, + }); + } + }), +}); diff --git a/packages/api/src/router/kubernetes/router/kubernetes-router.ts b/packages/api/src/router/kubernetes/router/kubernetes-router.ts new file mode 100644 index 000000000..ad9667c69 --- /dev/null +++ b/packages/api/src/router/kubernetes/router/kubernetes-router.ts @@ -0,0 +1,22 @@ +import { createTRPCRouter } from "../../../trpc"; +import { clusterRouter } from "./cluster"; +import { configMapsRouter } from "./configMaps"; +import { ingressesRouter } from "./ingresses"; +import { namespacesRouter } from "./namespaces"; +import { nodesRouter } from "./nodes"; +import { podsRouter } from "./pods"; +import { secretsRouter } from "./secrets"; +import { servicesRouter } from "./services"; +import { volumesRouter } from "./volumes"; + +export const kubernetesRouter = createTRPCRouter({ + nodes: nodesRouter, + cluster: clusterRouter, + namespaces: namespacesRouter, + ingresses: ingressesRouter, + services: servicesRouter, + pods: podsRouter, + secrets: secretsRouter, + configMaps: configMapsRouter, + volumes: volumesRouter, +}); diff --git a/packages/api/src/router/kubernetes/router/namespaces.ts b/packages/api/src/router/kubernetes/router/namespaces.ts new file mode 100644 index 000000000..90d5e62d9 --- /dev/null +++ b/packages/api/src/router/kubernetes/router/namespaces.ts @@ -0,0 +1,36 @@ +import { TRPCError } from "@trpc/server"; + +import type { KubernetesNamespace, KubernetesNamespaceState } from "@homarr/definitions"; +import { logger } from "@homarr/log"; + +import { kubernetesMiddleware } from "../../../middlewares/kubernetes"; +import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc"; +import { KubernetesClient } from "../kubernetes-client"; + +export const namespacesRouter = createTRPCRouter({ + getNamespaces: permissionRequiredProcedure + .requiresPermission("admin") + .unstable_concat(kubernetesMiddleware()) + .query(async (): Promise => { + const { coreApi } = KubernetesClient.getInstance(); + + try { + const namespaces = await coreApi.listNamespace(); + + return namespaces.items.map((namespace) => { + return { + status: namespace.status?.phase as KubernetesNamespaceState, + name: namespace.metadata?.name ?? "unknown", + creationTimestamp: namespace.metadata?.creationTimestamp, + } satisfies KubernetesNamespace; + }); + } catch (error) { + logger.error("Unable to retrieve namespaces", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "An error occurred while fetching Kubernetes namespaces", + cause: error, + }); + } + }), +}); diff --git a/packages/api/src/router/kubernetes/router/nodes.ts b/packages/api/src/router/kubernetes/router/nodes.ts new file mode 100644 index 000000000..c241c6628 --- /dev/null +++ b/packages/api/src/router/kubernetes/router/nodes.ts @@ -0,0 +1,68 @@ +import { TRPCError } from "@trpc/server"; + +import type { KubernetesNode, KubernetesNodeState } from "@homarr/definitions"; +import { logger } from "@homarr/log"; + +import { kubernetesMiddleware } from "../../../middlewares/kubernetes"; +import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc"; +import { KubernetesClient } from "../kubernetes-client"; +import { CpuResourceParser } from "../resource-parser/cpu-resource-parser"; +import { MemoryResourceParser } from "../resource-parser/memory-resource-parser"; + +export const nodesRouter = createTRPCRouter({ + getNodes: permissionRequiredProcedure + .requiresPermission("admin") + .unstable_concat(kubernetesMiddleware()) + .query(async (): Promise => { + const { coreApi, metricsApi } = KubernetesClient.getInstance(); + + try { + const nodes = await coreApi.listNode(); + const nodeMetricsClient = await metricsApi.getNodeMetrics(); + const cpuResourceParser = new CpuResourceParser(); + const memoryResourceParser = new MemoryResourceParser(); + + return nodes.items.map((node) => { + const name = node.metadata?.name ?? "unknown"; + + const readyCondition = node.status?.conditions?.find((condition) => condition.type === "Ready"); + const status: KubernetesNodeState = readyCondition?.status === "True" ? "Ready" : "NotReady"; + + const cpuAllocatable = cpuResourceParser.parse(node.status?.allocatable?.cpu ?? "0"); + + const memoryAllocatable = memoryResourceParser.parse(node.status?.allocatable?.memory ?? "0"); + + let cpuUsage = 0; + let memoryUsage = 0; + + const nodeMetric = nodeMetricsClient.items.find((metric) => metric.metadata.name === name); + if (nodeMetric) { + cpuUsage += cpuResourceParser.parse(nodeMetric.usage.cpu); + memoryUsage += memoryResourceParser.parse(nodeMetric.usage.memory); + } + + const usagePercentageCPUAllocatable = (cpuUsage / cpuAllocatable) * 100; + const usagePercentageMemoryAllocatable = (memoryUsage / memoryAllocatable) * 100; + + return { + name, + status, + allocatableCpuPercentage: Number(usagePercentageCPUAllocatable.toFixed(0)), + allocatableRamPercentage: Number(usagePercentageMemoryAllocatable.toFixed(0)), + podsCount: Number(node.status?.capacity?.pods), + operatingSystem: node.status?.nodeInfo?.operatingSystem, + architecture: node.status?.nodeInfo?.architecture, + kubernetesVersion: node.status?.nodeInfo?.kubeletVersion, + creationTimestamp: node.metadata?.creationTimestamp, + }; + }); + } catch (error) { + logger.error("Unable to retrieve nodes", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "An error occurred while fetching Kubernetes nodes", + cause: error, + }); + } + }), +}); diff --git a/packages/api/src/router/kubernetes/router/pods.ts b/packages/api/src/router/kubernetes/router/pods.ts new file mode 100644 index 000000000..bb785fa65 --- /dev/null +++ b/packages/api/src/router/kubernetes/router/pods.ts @@ -0,0 +1,104 @@ +import type { KubeConfig, V1OwnerReference } from "@kubernetes/client-node"; +import { AppsV1Api } from "@kubernetes/client-node"; +import { TRPCError } from "@trpc/server"; + +import type { KubernetesPod } from "@homarr/definitions"; +import { logger } from "@homarr/log"; + +import { kubernetesMiddleware } from "../../../middlewares/kubernetes"; +import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc"; +import { KubernetesClient } from "../kubernetes-client"; + +export const podsRouter = createTRPCRouter({ + getPods: permissionRequiredProcedure + .requiresPermission("admin") + .unstable_concat(kubernetesMiddleware()) + .query(async (): Promise => { + const { coreApi, kubeConfig } = KubernetesClient.getInstance(); + try { + const podsResp = await coreApi.listPodForAllNamespaces(); + + const pods: KubernetesPod[] = []; + + for (const pod of podsResp.items) { + const labels = pod.metadata?.labels ?? {}; + const ownerRefs = pod.metadata?.ownerReferences ?? []; + + let applicationType = "Pod"; + + if (labels["app.kubernetes.io/managed-by"] === "Helm") { + applicationType = "Helm"; + } else { + for (const owner of ownerRefs) { + if (["Deployment", "StatefulSet", "DaemonSet"].includes(owner.kind)) { + applicationType = owner.kind; + break; + } else if (owner.kind === "ReplicaSet") { + const ownerType = await getOwnerKind(kubeConfig, owner, pod.metadata?.namespace ?? ""); + if (ownerType) { + applicationType = ownerType; + break; + } + } + } + } + + pods.push({ + name: pod.metadata?.name ?? "", + namespace: pod.metadata?.namespace ?? "", + image: pod.spec?.containers.map((container) => container.image).join(", "), + applicationType, + status: pod.status?.phase ?? "unknown", + creationTimestamp: pod.metadata?.creationTimestamp, + }); + } + + return pods; + } catch (error) { + logger.error("Unable to retrieve pods", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "An error occurred while fetching Kubernetes pods", + cause: error, + }); + } + }), +}); + +async function getOwnerKind( + kubeConfig: KubeConfig, + ownerRef: V1OwnerReference, + namespace: string, +): Promise { + const { kind, name } = ownerRef; + + if (kind === "ReplicaSet") { + const appsApi = kubeConfig.makeApiClient(AppsV1Api); + try { + const rsResp = await appsApi.readNamespacedReplicaSet({ + name, + namespace, + }); + + if (rsResp.metadata?.ownerReferences) { + for (const rsOwner of rsResp.metadata.ownerReferences) { + if (rsOwner.kind === "Deployment") { + return "Deployment"; + } + const parentKind = await getOwnerKind(kubeConfig, rsOwner, namespace); + if (parentKind) return parentKind; + } + } + return "ReplicaSet"; + } catch (error) { + logger.error("Error reading ReplicaSet:", error); + return null; + } + } + + if (["Deployment", "StatefulSet", "DaemonSet"].includes(kind)) { + return kind; + } + + return null; +} diff --git a/packages/api/src/router/kubernetes/router/secrets.ts b/packages/api/src/router/kubernetes/router/secrets.ts new file mode 100644 index 000000000..471eab785 --- /dev/null +++ b/packages/api/src/router/kubernetes/router/secrets.ts @@ -0,0 +1,36 @@ +import { TRPCError } from "@trpc/server"; + +import type { KubernetesSecret } from "@homarr/definitions"; +import { logger } from "@homarr/log"; + +import { kubernetesMiddleware } from "../../../middlewares/kubernetes"; +import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc"; +import { KubernetesClient } from "../kubernetes-client"; + +export const secretsRouter = createTRPCRouter({ + getSecrets: permissionRequiredProcedure + .requiresPermission("admin") + .unstable_concat(kubernetesMiddleware()) + .query(async (): Promise => { + const { coreApi } = KubernetesClient.getInstance(); + try { + const secrets = await coreApi.listSecretForAllNamespaces(); + + return secrets.items.map((secret) => { + return { + name: secret.metadata?.name ?? "unknown", + namespace: secret.metadata?.namespace ?? "unknown", + type: secret.type ?? "unknown", + creationTimestamp: secret.metadata?.creationTimestamp, + }; + }); + } catch (error) { + logger.error("Unable to retrieve secrets", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "An error occurred while fetching Kubernetes secrets", + cause: error, + }); + } + }), +}); diff --git a/packages/api/src/router/kubernetes/router/services.ts b/packages/api/src/router/kubernetes/router/services.ts new file mode 100644 index 000000000..edb5c75ba --- /dev/null +++ b/packages/api/src/router/kubernetes/router/services.ts @@ -0,0 +1,40 @@ +import { TRPCError } from "@trpc/server"; + +import type { KubernetesService } from "@homarr/definitions"; +import { logger } from "@homarr/log"; + +import { kubernetesMiddleware } from "../../../middlewares/kubernetes"; +import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc"; +import { KubernetesClient } from "../kubernetes-client"; + +export const servicesRouter = createTRPCRouter({ + getServices: permissionRequiredProcedure + .requiresPermission("admin") + .unstable_concat(kubernetesMiddleware()) + .query(async (): Promise => { + const { coreApi } = KubernetesClient.getInstance(); + + try { + const services = await coreApi.listServiceForAllNamespaces(); + + return services.items.map((service) => { + return { + name: service.metadata?.name ?? "unknown", + namespace: service.metadata?.namespace ?? "", + type: service.spec?.type ?? "", + ports: service.spec?.ports?.map(({ port, protocol }) => `${port}/${protocol}`), + targetPorts: service.spec?.ports?.map(({ targetPort }) => `${targetPort}`), + clusterIP: service.spec?.clusterIP ?? "", + creationTimestamp: service.metadata?.creationTimestamp, + }; + }); + } catch (error) { + logger.error("Unable to retrieve services", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "An error occurred while fetching Kubernetes services", + cause: error, + }); + } + }), +}); diff --git a/packages/api/src/router/kubernetes/router/volumes.ts b/packages/api/src/router/kubernetes/router/volumes.ts new file mode 100644 index 000000000..b2e06df1a --- /dev/null +++ b/packages/api/src/router/kubernetes/router/volumes.ts @@ -0,0 +1,42 @@ +import { TRPCError } from "@trpc/server"; + +import type { KubernetesVolume } from "@homarr/definitions"; +import { logger } from "@homarr/log"; + +import { kubernetesMiddleware } from "../../../middlewares/kubernetes"; +import { createTRPCRouter, permissionRequiredProcedure } from "../../../trpc"; +import { KubernetesClient } from "../kubernetes-client"; + +export const volumesRouter = createTRPCRouter({ + getVolumes: permissionRequiredProcedure + .requiresPermission("admin") + .unstable_concat(kubernetesMiddleware()) + .query(async (): Promise => { + const { coreApi } = KubernetesClient.getInstance(); + + try { + const volumes = await coreApi.listPersistentVolumeClaimForAllNamespaces(); + + return volumes.items.map((volume) => { + return { + name: volume.metadata?.name ?? "unknown", + namespace: volume.metadata?.namespace ?? "unknown", + accessModes: volume.status?.accessModes?.map((accessMode) => accessMode) ?? [], + storage: volume.status?.capacity?.storage ?? "", + storageClassName: volume.spec?.storageClassName ?? "", + volumeMode: volume.spec?.volumeMode ?? "", + volumeName: volume.spec?.volumeName ?? "", + status: volume.status?.phase ?? "", + creationTimestamp: volume.metadata?.creationTimestamp, + }; + }); + } catch (error) { + logger.error("Unable to retrieve volumes", error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "An error occurred while fetching Kubernetes Volumes", + cause: error, + }); + } + }), +}); 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 24e3793c4..2becec4e6 100644 --- a/packages/api/src/router/test/docker/docker-router.spec.ts +++ b/packages/api/src/router/test/docker/docker-router.spec.ts @@ -24,6 +24,12 @@ vi.mock("@homarr/redis", () => ({ }), })); +vi.mock("@homarr/docker/env", () => ({ + env: { + ENABLE_DOCKER: true, + }, +})); + const createSessionWithPermissions = (...permissions: GroupPermissionKey[]) => ({ user: { diff --git a/packages/api/src/router/test/kubernetes/resource-parser/cpu-resource-parser.spec.ts b/packages/api/src/router/test/kubernetes/resource-parser/cpu-resource-parser.spec.ts new file mode 100644 index 000000000..819dce1ff --- /dev/null +++ b/packages/api/src/router/test/kubernetes/resource-parser/cpu-resource-parser.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { CpuResourceParser } from "../../../kubernetes/resource-parser/cpu-resource-parser"; + +describe("CpuResourceParser", () => { + const parser = new CpuResourceParser(); + + it("should return NaN for empty or invalid input", () => { + expect(parser.parse("")).toBeNaN(); + expect(parser.parse(" ")).toBeNaN(); + expect(parser.parse("abc")).toBeNaN(); + }); + + it("should parse CPU values without a unit (cores)", () => { + expect(parser.parse("1")).toBe(1); + expect(parser.parse("2.5")).toBe(2.5); + expect(parser.parse("10")).toBe(10); + }); + + it("should parse CPU values with milli-core unit ('m')", () => { + expect(parser.parse("500m")).toBe(0.5); // 500 milli-cores = 0.5 cores + expect(parser.parse("250m")).toBe(0.25); + expect(parser.parse("1000m")).toBe(1); + }); + + it("should parse CPU values with kilo-core unit ('k')", () => { + expect(parser.parse("1k")).toBe(1000); // 1 kilo-core = 1000 cores + expect(parser.parse("2k")).toBe(2000); + expect(parser.parse("0.5k")).toBe(500); + }); + + it("should parse CPU values with nano-core unit ('n')", () => { + // Adjust the expected values for nano-cores to account for floating-point precision + expect(parser.parse("1000000000n")).toBe(1); // 1 NanoCPU = 1/1,000,000,000 cores + expect(parser.parse("500000000n")).toBe(0.5); + expect(parser.parse("0.000000001n")).toBe(0.000000000000000001); // Tiny value + }); + + it("should parse CPU values with micro-core unit ('u')", () => { + // Adjust the expected values for micro-cores to account for floating-point precision + expect(parser.parse("1000000u")).toBe(1); // 1 MicroCPU = 1/1,000,000 cores + expect(parser.parse("500000u")).toBe(0.5); + expect(parser.parse("0.000001u")).toBe(0.000000000001); // Tiny value + }); + + it("should handle input with commas", () => { + expect(parser.parse("1,000")).toBe(1000); // 1,000 cores + expect(parser.parse("1,500m")).toBe(1.5); // 1,500 milli-cores = 1.5 cores + }); + + it("should ignore leading and trailing whitespace", () => { + expect(parser.parse(" 1 ")).toBe(1); + expect(parser.parse(" 500m ")).toBe(0.5); + expect(parser.parse(" 2k ")).toBe(2000); + }); +}); diff --git a/packages/api/src/router/test/kubernetes/resource-parser/memory-resource-parser.spec.ts b/packages/api/src/router/test/kubernetes/resource-parser/memory-resource-parser.spec.ts new file mode 100644 index 000000000..f56f78237 --- /dev/null +++ b/packages/api/src/router/test/kubernetes/resource-parser/memory-resource-parser.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import { MemoryResourceParser } from "../../../kubernetes/resource-parser/memory-resource-parser"; + +const BYTES_IN_GIB = 1024 ** 3; // 1 GiB in bytes +const BYTES_IN_MIB = 1024 ** 2; // 1 MiB in bytes +const BYTES_IN_KIB = 1024; // 1 KiB in bytes +const KI = "Ki"; +const MI = "Mi"; +const GI = "Gi"; +const TI = "Ti"; +const PI = "Pi"; + +describe("MemoryResourceParser", () => { + const parser = new MemoryResourceParser(); + + it("should parse values without units as bytes and convert to GiB", () => { + expect(parser.parse("1073741824")).toBe(1); // 1 GiB + expect(parser.parse("2147483648")).toBe(2); // 2 GiB + }); + + it("should parse binary units (Ki, Mi, Gi, Ti, Pi) into GiB", () => { + expect(parser.parse(`1024${KI}`)).toBeCloseTo(1 / 1024); // 1 MiB = 1/1024 GiB + expect(parser.parse(`1${MI}`)).toBeCloseTo(1 / 1024); // 1 MiB = 1/1024 GiB + expect(parser.parse(`1${GI}`)).toBe(1); // 1 GiB + expect(parser.parse(`1${TI}`)).toBe(BYTES_IN_KIB); // 1 TiB = 1024 GiB + expect(parser.parse(`1${PI}`)).toBe(BYTES_IN_MIB); // 1 PiB = 1024^2 GiB + }); + + it("should parse decimal units (K, M, G, T, P) into GiB", () => { + expect(parser.parse("1000K")).toBeCloseTo(1000 / BYTES_IN_GIB); // 1000 KB + expect(parser.parse("1M")).toBeCloseTo(1 / BYTES_IN_KIB); // 1 MB = 1/1024 GiB + expect(parser.parse("1G")).toBeCloseTo(0.9313225746154785); // 1 GB ≈ 0.931 GiB + expect(parser.parse("1T")).toBeCloseTo(931.3225746154785); // 1 TB ≈ 931.32 GiB + expect(parser.parse("1P")).toBeCloseTo(931322.5746154785); // 1 PB ≈ 931,322.57 GiB + }); + + it("should handle invalid input and return NaN", () => { + expect(parser.parse("")).toBeNaN(); + expect(parser.parse(" ")).toBeNaN(); + expect(parser.parse("abc")).toBeNaN(); + }); + + it("should handle commas in input and convert to GiB", () => { + expect(parser.parse("1,073,741,824")).toBe(1); // 1 GiB + expect(parser.parse("1,024Ki")).toBeCloseTo(1 / BYTES_IN_KIB); // 1 MiB + }); + + it("should handle lowercase and uppercase units", () => { + expect(parser.parse("1ki")).toBeCloseTo(1 / BYTES_IN_KIB); // 1 MiB + expect(parser.parse("1KI")).toBeCloseTo(1 / BYTES_IN_KIB); + expect(parser.parse("1Mi")).toBeCloseTo(1 / BYTES_IN_KIB); + expect(parser.parse("1m")).toBeCloseTo(1 / BYTES_IN_KIB); + }); + + it("should assume bytes for unrecognized or no units and convert to GiB", () => { + expect(parser.parse("1073741824")).toBe(1); // 1 GiB + expect(parser.parse("42")).toBeCloseTo(42 / BYTES_IN_GIB); // 42 bytes in GiB + expect(parser.parse("42unknown")).toBeCloseTo(42 / BYTES_IN_GIB); // Invalid unit = bytes + }); +}); diff --git a/packages/definitions/src/index.ts b/packages/definitions/src/index.ts index 45f655491..22ca76b84 100644 --- a/packages/definitions/src/index.ts +++ b/packages/definitions/src/index.ts @@ -4,6 +4,7 @@ export * from "./section"; export * from "./widget"; export * from "./permissions"; export * from "./docker"; +export * from "./kubernetes"; export * from "./auth"; export * from "./user"; export * from "./group"; diff --git a/packages/definitions/src/kubernetes.ts b/packages/definitions/src/kubernetes.ts new file mode 100644 index 000000000..e6a6c710a --- /dev/null +++ b/packages/definitions/src/kubernetes.ts @@ -0,0 +1,110 @@ +export const kubernetesNodeStates = ["Ready", "NotReady"] as const; +export const kubernetesNamespaceStates = ["Active", "Terminating"] as const; +export const kubernetesResourceTypes = ["Reserved", "Used"] as const; +export const kubernetesCapacityTypes = ["Pods", "CPU", "Memory"] as const; +export const kubernetesLabelResourceTypes = [ + "configmaps", + "pods", + "ingresses", + "namespaces", + "nodes", + "secrets", + "services", + "volumes", +] as const; + +export type KubernetesNodeState = (typeof kubernetesNodeStates)[number]; +export type KubernetesNamespaceState = (typeof kubernetesNamespaceStates)[number]; +export type KubernetesResourceType = (typeof kubernetesResourceTypes)[number]; +export type KubernetesCapacityType = (typeof kubernetesCapacityTypes)[number]; +export type KubernetesLabelResourceType = (typeof kubernetesLabelResourceTypes)[number]; + +export interface KubernetesBaseResource { + name: string; + namespace?: string; + creationTimestamp?: Date; +} + +export interface KubernetesVolume extends KubernetesBaseResource { + accessModes: string[]; + storage: string; + storageClassName: string; + volumeMode: string; + volumeName: string; + status: string; +} + +export interface KubernetesSecret extends KubernetesBaseResource { + type: string; +} + +export interface KubernetesPod extends KubernetesBaseResource { + image?: string; + applicationType: string; + status: string; +} + +export interface KubernetesService extends KubernetesBaseResource { + type: string; + ports?: string[]; + targetPorts?: string[]; + clusterIP: string; +} + +export interface KubernetesIngressPath { + serviceName: string; + port: number; +} + +export interface KubernetesIngressRuleAndPath { + host: string; + paths: KubernetesIngressPath[]; +} + +export interface KubernetesIngress extends KubernetesBaseResource { + className: string; + rulesAndPaths: KubernetesIngressRuleAndPath[]; +} + +export interface KubernetesNamespace extends KubernetesBaseResource { + status: KubernetesNamespaceState; +} + +export interface KubernetesNode { + name: string; + status: KubernetesNodeState; + allocatableCpuPercentage: number; + allocatableRamPercentage: number; + podsCount: number; + operatingSystem?: string; + architecture?: string; + kubernetesVersion?: string; + creationTimestamp?: Date; +} + +export interface KubernetesCluster { + name: string; + providers: string; + kubernetesVersion: string; + architecture: string; + nodeCount: number; + capacity: KubernetesCapacity[]; +} + +export interface KubernetesCapacity { + type: KubernetesCapacityType; + resourcesStats: KubernetesResourceStat[]; +} + +export interface KubernetesResourceStat { + percentageValue: number; + type: KubernetesResourceType; + capacityUnit?: string; + usedValue: number; + maxUsedValue: number; +} + +export interface ClusterResourceCount { + label: string; + count: number; +} diff --git a/packages/docker/src/env.ts b/packages/docker/src/env.ts index 437684454..e71a26a66 100644 --- a/packages/docker/src/env.ts +++ b/packages/docker/src/env.ts @@ -1,12 +1,15 @@ import { z } from "zod"; import { createEnv } from "@homarr/env"; +import { createBooleanSchema } from "@homarr/env/schemas"; export const env = createEnv({ server: { // Comma separated list of docker hostnames that can be used to connect to query the docker endpoints (localhost:2375,host.docker.internal:2375, ...) DOCKER_HOSTNAMES: z.string().optional(), DOCKER_PORTS: z.string().optional(), + ENABLE_DOCKER: createBooleanSchema(true), + ENABLE_KUBERNETES: createBooleanSchema(false), }, experimental__runtimeEnv: process.env, }); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 7c664d97d..ccc7cf6ca 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2321,6 +2321,7 @@ "label": "Tools", "items": { "docker": "Docker", + "kubernetes": "Kubernetes", "logs": "Logs", "api": "API", "certificates": "Certificates", @@ -2754,7 +2755,7 @@ "title": "Containers", "table": { "updated": "Updated {when}", - "search": "Seach {count} containers", + "search": "Search {count} containers", "selected": "{selectCount} of {totalCount} containers selected" }, "field": { @@ -2867,6 +2868,246 @@ "internalServerError": "Failed to fetch Docker containers" } }, + "kubernetes": { + "cluster": { + "title": "Cluster Dashboard", + "label": "Cluster", + "providers": "Providers", + "version": "Version", + "architecture": "Architecture", + "capacity": { + "title": "Capacity", + "resource": { + "reserved": "Reserved", + "used": "Used" + } + }, + "resources": { + "title": "Resources", + "nodes": "Nodes", + "namespaces": "Namespaces", + "ingresses": "Ingresses", + "services": "Services", + "pods": "Pods", + "configmaps": "ConfigMaps", + "secrets": "Secrets", + "volumes": "Volumes" + } + }, + "nodes": { + "label": "Nodes", + "field": { + "name": { + "label": "Name" + }, + "state": { + "label": "State", + "option": { + "ready": "Ready", + "NotReady": "Not Ready" + } + }, + "cpu": { + "label": "CPU" + }, + "memory": { + "label": "RAM" + }, + "pods": { + "label": "Pods" + }, + "operatingSystem": { + "label": "OS" + }, + "architecture": { + "label": "Architecture" + }, + "kubernetesVersion": { + "label": "Kubernetes version" + }, + "creationTimestamp": { + "label": "Created" + } + }, + "table": { + "search": "Search {count} nodes" + } + }, + "namespaces": { + "label": "Namespaces", + "field": { + "name": { + "label": "Name" + }, + "state": { + "label": "State", + "option": { + "active": "Active", + "terminating": "Terminating" + } + }, + "creationTimestamp": { + "label": "Created" + } + }, + "table": { + "search": "Search {count} namespaces" + } + }, + "ingresses": { + "label": "Ingresses", + "field": { + "name": { + "label": "Name" + }, + "namespace": { + "label": "Namespace" + }, + "className": { + "label": "Class name" + }, + "rulesAndPaths": { + "label": "Rules & paths" + }, + "creationTimestamp": { + "label": "Created" + } + }, + "table": { + "search": "Search {count} ingresses" + } + }, + "services": { + "label": "Services", + "field": { + "name": { + "label": "Name" + }, + "namespace": { + "label": "Namespace" + }, + "type": { + "label": "Type" + }, + "ports": { + "label": "Ports" + }, + "targetPorts": { + "label": "Target ports" + }, + "clusterIP": { + "label": "Cluster IP" + }, + "creationTimestamp": { + "label": "Created" + } + }, + "table": { + "search": "Search {count} services" + } + }, + "pods": { + "label": "Pods", + "field": { + "name": { + "label": "Name" + }, + "namespace": { + "label": "Namespace" + }, + "image": { + "label": "Image" + }, + "applicationType": { + "label": "Application type" + }, + "status": { + "label": "Status" + }, + "creationTimestamp": { + "label": "Created" + } + }, + "table": { + "search": "Search {count} pods" + } + }, + "secrets": { + "label": "Secrets", + "field": { + "name": { + "label": "Name" + }, + "namespace": { + "label": "namespace" + }, + "type": { + "label": "type" + }, + "creationTimestamp": { + "label": "Created" + } + }, + "table": { + "search": "Search {count} secrets" + } + }, + "configmaps": { + "label": "ConfigMaps", + "field": { + "name": { + "label": "Name" + }, + "namespace": { + "label": "namespace" + }, + "creationTimestamp": { + "label": "Created" + } + }, + "table": { + "search": "Search {count} configMaps" + } + }, + "volumes": { + "label": "Volumes", + "field": { + "name": { + "label": "Name" + }, + "namespace": { + "label": "Namespace" + }, + "accessModes": { + "label": "Access Modes" + }, + "storage": { + "label": "Storage" + }, + "storageClassName": { + "label": "Storage Class Name" + }, + "volumeMode": { + "label": "Volume Mode" + }, + "volumeName": { + "label": "Volume Name" + }, + "status": { + "label": "Status" + }, + "creationTimestamp": { + "label": "Created" + } + }, + "table": { + "search": "Search {count} volumes" + } + }, + "error": { + "internalServerError": "Failed to fetch Kubernetes data" + } + }, "permission": { "title": "Permissions", "userSelect": { @@ -2952,6 +3193,33 @@ "docker": { "label": "Docker" }, + "kubernetes": { + "label": "Kubernetes", + "nodes": { + "label": "Nodes" + }, + "namespaces": { + "label": "Namespaces" + }, + "ingresses": { + "label": "Ingresses" + }, + "services": { + "label": "Services" + }, + "pods": { + "label": "pods" + }, + "configmaps": { + "label": "ConfigMaps" + }, + "secrets": { + "label": "Secrets" + }, + "volumes": { + "label": "Volumes" + } + }, "logs": { "label": "Logs" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ef31b534..b7333a3eb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -578,6 +578,9 @@ importers: '@homarr/validation': specifier: workspace:^0.1.0 version: link:../validation + '@kubernetes/client-node': + specifier: ^1.0.0 + version: 1.0.0 '@trpc/client': specifier: next version: 11.0.0-rc.824(@trpc/server@11.0.0-rc.824(typescript@5.8.2))(typescript@5.8.2) @@ -1480,7 +1483,7 @@ importers: version: link:../ui '@mantine/notifications': specifier: ^7.17.1 - version: 7.17.1(@mantine/core@7.17.1(@mantine/hooks@7.17.1(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@mantine/hooks@7.17.1(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 7.17.1(@mantine/core@7.17.1(@mantine/hooks@7.17.1(react@19.0.0))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@mantine/hooks@7.17.1(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@tabler/icons-react': specifier: ^3.31.0 version: 3.31.0(react@19.0.0) @@ -1833,7 +1836,7 @@ importers: version: 7.17.1(react@19.0.0) '@mantine/spotlight': specifier: ^7.17.1 - version: 7.17.1(@mantine/core@7.17.1(@mantine/hooks@7.17.1(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@mantine/hooks@7.17.1(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + version: 7.17.1(@mantine/core@7.17.1(@mantine/hooks@7.17.1(react@19.0.0))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@mantine/hooks@7.17.1(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@tabler/icons-react': specifier: ^3.31.0 version: 3.31.0(react@19.0.0) @@ -3435,6 +3438,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@istanbuljs/schema@0.1.3': resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} @@ -3471,6 +3478,21 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@jsep-plugin/assignment@1.3.0': + resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==} + engines: {node: '>= 10.16.0'} + peerDependencies: + jsep: ^0.4.0||^1.0.0 + + '@jsep-plugin/regex@1.0.4': + resolution: {integrity: sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==} + engines: {node: '>= 10.16.0'} + peerDependencies: + jsep: ^0.4.0||^1.0.0 + + '@kubernetes/client-node@1.0.0': + resolution: {integrity: sha512-a8NSvFDSHKFZ0sR1hbPSf8IDFNJwctEU5RodSCNiq/moRXWmrdmqhb1RRQzF+l+TSBaDgHw3YsYNxxE92STBzw==} + '@libsql/client-wasm@0.14.0': resolution: {integrity: sha512-gB/jtz0xuwrqAHApBv9e9JSew2030Fhj2edyZ83InZ4yPj/Q2LTUlEhaspEYT0T0xsAGqPy38uGrmq/OGS+DdQ==} bundledDependencies: @@ -4664,6 +4686,9 @@ packages: '@types/inquirer@6.5.0': resolution: {integrity: sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -4691,6 +4716,9 @@ packages: '@types/node-cron@3.0.11': resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} + '@types/node-fetch@2.6.12': + resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} + '@types/node@18.19.50': resolution: {integrity: sha512-xonK+NRrMBRtkL1hVCc3G+uXtjh1Al4opBLjqVmipe5ZAaBYWW6cNAiBVZ1BvmkBhep698rP3UM3aRAdSALuhg==} @@ -4738,9 +4766,15 @@ packages: '@types/ssh2@1.15.1': resolution: {integrity: sha512-ZIbEqKAsi5gj35y4P4vkJYly642wIbY6PqoN0xiyQGshKUGXR9WQjF/iF9mXBQ8uBKy3ezfsCkcoHKhd0BzuDA==} + '@types/stream-buffers@3.0.7': + resolution: {integrity: sha512-azOCy05sXVXrO+qklf0c/B07H/oHaIuDDAiHPVwlk3A9Ek+ksHyTeMajLZl3r76FxpPpxem//4Te61G1iW3Giw==} + '@types/swagger-ui-react@5.18.0': resolution: {integrity: sha512-c2M9adVG7t28t1pq19K9Jt20VLQf0P/fwJwnfcmsVVsdkwCWhRmbKDu+tIs0/NGwJ/7GY8lBx+iKZxuDI5gDbw==} + '@types/tar@6.1.13': + resolution: {integrity: sha512-IznnlmU5f4WcGTh2ltRu/Ijpmk8wiWXfF0VA4s+HPjHZgvFggk1YaIkbo5krX/zUCzWF8N/l4+W/LNxnvAJ8nw==} + '@types/through@0.0.33': resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} @@ -5404,6 +5438,10 @@ packages: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + chroma-js@3.1.2: resolution: {integrity: sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg==} @@ -7203,6 +7241,11 @@ packages: isomorphic-fetch@3.0.0: resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==} + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + issue-parser@7.0.1: resolution: {integrity: sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==} engines: {node: ^18.17 || >=20.6.1} @@ -7285,6 +7328,10 @@ packages: canvas: optional: true + jsep@1.4.0: + resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==} + engines: {node: '>= 10.16.0'} + jsesc@3.0.2: resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} engines: {node: '>=6'} @@ -7317,6 +7364,11 @@ packages: jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + jsonpath-plus@10.3.0: + resolution: {integrity: sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA==} + engines: {node: '>=18.0.0'} + hasBin: true + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -7616,6 +7668,10 @@ packages: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} engines: {node: '>=8'} + minipass@4.2.8: + resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} + engines: {node: '>=8'} + minipass@5.0.0: resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} engines: {node: '>=8'} @@ -7628,6 +7684,10 @@ packages: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} + minizlib@3.0.1: + resolution: {integrity: sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==} + engines: {node: '>= 18'} + mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} @@ -7643,6 +7703,11 @@ packages: engines: {node: '>=10'} hasBin: true + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + moment-timezone@0.5.47: resolution: {integrity: sha512-UbNt/JAWS0m/NJOebR0QMRHBk0hu03r5dx9GK8Cs0AS3I81yDcOc9k+DytPItgVvBP7J6Mf6U2n3BPAacAV9oA==} @@ -8044,6 +8109,9 @@ packages: openapi3-ts@4.4.0: resolution: {integrity: sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw==} + openid-client@6.3.3: + resolution: {integrity: sha512-lTK8AV8SjqCM4qznLX0asVESAwzV39XTVdfMAM185ekuaZCnkWdPzcxMTXNlsm9tsUAMa1Q30MBmKAykdT1LWw==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -8805,6 +8873,10 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + rollup@4.21.3: resolution: {integrity: sha512-7sqRtBNnEbcBtMeRVc6VRsJMmpI+JU1z9VTvW8D4gXIYQFz0aLcsE6rRkyghZkLfEgUZgVvOG7A5CVz/VW5GIA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -9143,6 +9215,10 @@ packages: std-env@3.8.0: resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==} + stream-buffers@3.0.3: + resolution: {integrity: sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==} + engines: {node: '>= 0.10.0'} + stream-combiner2@1.1.1: resolution: {integrity: sha512-3PnJbYgS56AeWgtKF5jtJRT6uFJe56Z0Hc5Ngg/6sI6rIt8iiMBTa9cvdyFfpMQjaVHr8dusbNeFGIIonxOvKw==} @@ -9331,6 +9407,10 @@ packages: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + tar@7.4.3: + resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} + engines: {node: '>=18'} + temp-dir@3.0.0: resolution: {integrity: sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==} engines: {node: '>=14.16'} @@ -9434,6 +9514,9 @@ packages: resolution: {integrity: sha512-Oh/CqRQ1NXNY7cy9NkTPUauOWiTro0jEYZTioGbOmcQh6EC45oribyIMJp0OJO3677r13tO6SKdWoGZUx2BDFw==} hasBin: true + tmp-promise@3.0.3: + resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -10200,6 +10283,10 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@2.5.1: resolution: {integrity: sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==} engines: {node: '>= 14'} @@ -11154,6 +11241,10 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.2 + '@istanbuljs/schema@0.1.3': {} '@jellyfin/sdk@0.11.0(axios@1.7.7)': @@ -11189,6 +11280,39 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} + '@jsep-plugin/assignment@1.3.0(jsep@1.4.0)': + dependencies: + jsep: 1.4.0 + + '@jsep-plugin/regex@1.0.4(jsep@1.4.0)': + dependencies: + jsep: 1.4.0 + + '@kubernetes/client-node@1.0.0': + dependencies: + '@types/js-yaml': 4.0.9 + '@types/node': 22.13.10 + '@types/node-fetch': 2.6.12 + '@types/stream-buffers': 3.0.7 + '@types/tar': 6.1.13 + '@types/ws': 8.18.0 + form-data: 4.0.1 + isomorphic-ws: 5.0.0(ws@8.18.1) + js-yaml: 4.1.0 + jsonpath-plus: 10.3.0 + node-fetch: 2.7.0 + openid-client: 6.3.3 + rfc4648: 1.5.3 + stream-buffers: 3.0.3 + tar: 7.4.3 + tmp-promise: 3.0.3 + tslib: 2.8.1 + ws: 8.18.1 + transitivePeerDependencies: + - bufferutil + - encoding + - utf-8-validate + '@libsql/client-wasm@0.14.0': dependencies: '@libsql/core': 0.14.0 @@ -11252,7 +11376,7 @@ snapshots: react: 19.0.0 react-dom: 19.0.0(react@19.0.0) - '@mantine/notifications@7.17.1(@mantine/core@7.17.1(@mantine/hooks@7.17.1(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@mantine/hooks@7.17.1(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + '@mantine/notifications@7.17.1(@mantine/core@7.17.1(@mantine/hooks@7.17.1(react@19.0.0))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@mantine/hooks@7.17.1(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@mantine/core': 7.17.1(@mantine/hooks@7.17.1(react@19.0.0))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@mantine/hooks': 7.17.1(react@19.0.0) @@ -11261,7 +11385,7 @@ snapshots: react-dom: 19.0.0(react@19.0.0) react-transition-group: 4.4.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - '@mantine/spotlight@7.17.1(@mantine/core@7.17.1(@mantine/hooks@7.17.1(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@mantine/hooks@7.17.1(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + '@mantine/spotlight@7.17.1(@mantine/core@7.17.1(@mantine/hooks@7.17.1(react@19.0.0))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@mantine/hooks@7.17.1(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@mantine/core': 7.17.1(@mantine/hooks@7.17.1(react@19.0.0))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@mantine/hooks': 7.17.1(react@19.0.0) @@ -12806,6 +12930,8 @@ snapshots: '@types/through': 0.0.33 rxjs: 6.6.7 + '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -12827,6 +12953,11 @@ snapshots: '@types/node-cron@3.0.11': {} + '@types/node-fetch@2.6.12': + dependencies: + '@types/node': 22.13.10 + form-data: 4.0.1 + '@types/node@18.19.50': dependencies: undici-types: 5.26.5 @@ -12881,10 +13012,19 @@ snapshots: dependencies: '@types/node': 18.19.50 + '@types/stream-buffers@3.0.7': + dependencies: + '@types/node': 22.13.10 + '@types/swagger-ui-react@5.18.0': dependencies: '@types/react': 19.0.10 + '@types/tar@6.1.13': + dependencies: + '@types/node': 22.13.10 + minipass: 4.2.8 + '@types/through@0.0.33': dependencies: '@types/node': 22.13.10 @@ -13708,6 +13848,8 @@ snapshots: chownr@2.0.0: {} + chownr@3.0.0: {} + chroma-js@3.1.2: {} chrome-trace-event@1.0.4: {} @@ -15777,6 +15919,10 @@ snapshots: transitivePeerDependencies: - encoding + isomorphic-ws@5.0.0(ws@8.18.1): + dependencies: + ws: 8.18.1 + issue-parser@7.0.1: dependencies: lodash.capitalize: 4.2.1 @@ -15885,6 +16031,8 @@ snapshots: - supports-color - utf-8-validate + jsep@1.4.0: {} + jsesc@3.0.2: {} json-buffer@3.0.1: {} @@ -15909,6 +16057,12 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonpath-plus@10.3.0: + dependencies: + '@jsep-plugin/assignment': 1.3.0(jsep@1.4.0) + '@jsep-plugin/regex': 1.0.4(jsep@1.4.0) + jsep: 1.4.0 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.8 @@ -16194,6 +16348,8 @@ snapshots: dependencies: yallist: 4.0.0 + minipass@4.2.8: {} + minipass@5.0.0: {} minipass@7.1.2: {} @@ -16203,6 +16359,11 @@ snapshots: minipass: 3.3.6 yallist: 4.0.0 + minizlib@3.0.1: + dependencies: + minipass: 7.1.2 + rimraf: 5.0.10 + mitt@3.0.1: {} mkdirp-classic@0.5.3: {} @@ -16213,6 +16374,8 @@ snapshots: mkdirp@1.0.4: {} + mkdirp@3.0.1: {} + moment-timezone@0.5.47: dependencies: moment: 2.30.1 @@ -16561,6 +16724,11 @@ snapshots: dependencies: yaml: 2.5.1 + openid-client@6.3.3: + dependencies: + jose: 6.0.8 + oauth4webapi: 3.3.0 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -17413,6 +17581,10 @@ snapshots: dependencies: glob: 7.2.3 + rimraf@5.0.10: + dependencies: + glob: 10.4.5 + rollup@4.21.3: dependencies: '@types/estree': 1.0.5 @@ -17873,6 +18045,8 @@ snapshots: std-env@3.8.0: {} + stream-buffers@3.0.3: {} + stream-combiner2@1.1.1: dependencies: duplexer2: 0.1.4 @@ -18161,6 +18335,15 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 + tar@7.4.3: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.2 + minizlib: 3.0.1 + mkdirp: 3.0.1 + yallist: 5.0.0 + temp-dir@3.0.0: {} tempy@3.1.0: @@ -18276,6 +18459,10 @@ snapshots: dependencies: tldts-core: 6.1.69 + tmp-promise@3.0.3: + dependencies: + tmp: 0.2.3 + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -19079,6 +19266,8 @@ snapshots: yallist@4.0.0: {} + yallist@5.0.0: {} + yaml@2.5.1: {} yargs-parser@20.2.9: {}