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 @@
+
+
+
+
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 @@
+
+
+
+
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 @@
+
+
+
+
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 @@
+
+
+
+
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 @@
+
+
+
+
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 @@
+
+
+
+
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 @@
+
+
+
+
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 @@
+
+
+
+
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.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: {}