feat(kubernetes): add kubernetes tool (#1929)

Co-authored-by: oussama Dahmaz <dahmaz@MacBook-Pro-de-odahmaz.local>
This commit is contained in:
oussama Dahmaz
2025-03-09 17:46:15 +01:00
committed by GitHub
parent f6f0d7c72b
commit f19aa29607
67 changed files with 3897 additions and 67 deletions

View File

@@ -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"),

View File

@@ -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();
}

View File

@@ -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 (
<Stack bg="var(--mantine-color-body)">
<Title>{t("kubernetes.cluster.title")}</Title>
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{isClusterError ? (
Array.from({ length: 3 }).map(() => <KubernetesErrorPage key={createId()} />)
) : isClusterLoading ? (
Array.from({ length: 3 }).map(() => <Skeleton key={createId()} height={65} />)
) : (
<>
<HeaderCard headerType={"providers"} value={clusterData ? clusterData.providers : ""} />
<HeaderCard headerType={"version"} value={clusterData ? clusterData.kubernetesVersion : ""} />
<HeaderCard headerType={"architecture"} value={clusterData ? clusterData.architecture : ""} />
</>
)}
</SimpleGrid>
<Title>{t("kubernetes.cluster.capacity.title")}</Title>
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{isClusterError
? Array.from({ length: 3 }).map(() => <KubernetesErrorPage key={createId()} />)
: isClusterLoading
? Array.from({ length: 3 }).map(() => <Skeleton key={createId()} height={200} />)
: clusterData?.capacity.map((capacity) => (
<ResourceGauge kubernetesCapacity={capacity} key={capacity.type} />
))}
</SimpleGrid>
<Title>{t("kubernetes.cluster.resources.title")}</Title>
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{isResourceCountsError
? Array.from({ length: 8 }).map(() => <KubernetesErrorPage key={createId()} />)
: isResourceCountsLoading
? Array.from({ length: 8 }).map(() => <Skeleton key={createId()} height={100} />)
: resourceCountsData?.map((clusterResourceCount) => (
<ResourceTile
count={clusterResourceCount.count}
label={clusterResourceCount.label as KubernetesLabelResourceType}
key={clusterResourceCount.label}
/>
))}
</SimpleGrid>
</Stack>
);
}

View File

@@ -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 (
<Center>
<Stack align="center">
<IconCubeOff size={48} stroke={1.5} />
<Stack align="center" gap="xs">
<Text size="lg" fw={500}>
{t("kubernetes.error.internalServerError")}
</Text>
<Anchor size="sm" component={Link} href="/manage/tools/logs">
{t("common.action.checkLogs")}
</Anchor>
</Stack>
</Stack>
</Center>
);
}

View File

@@ -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;
}
}
}

View File

@@ -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 (
<Card className={classes.header}>
<Flex align="center" justify={isRTL ? "space-between" : "flex-start"} gap="md" direction={"row"}>
<ThemeIcon
size="xl"
radius="md"
variant="gradient"
gradient={{
deg: 0,
from: "var(--mantine-color-blue-4)",
to: "var(--mantine-color-blue-9)",
}}
>
<HeaderIcon type={props.headerType} />
</ThemeIcon>
<Text size="xl" fw={500} dir={isRTL ? "rtl" : "ltr"}>
{isRTL
? `${props.value} : ${t(`kubernetes.cluster.${props.headerType}`)}`
: `${t(`kubernetes.cluster.${props.headerType}`)} : ${props.value}`}
</Text>
</Flex>
</Card>
);
}

View File

@@ -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 <IconCloudShare size={28} stroke={1.5} />;
case "version":
return <IconBrandGit size={28} stroke={1.5} />;
default:
return <IconGeometry size={28} stroke={1.5} />;
}
}

View File

@@ -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;
}

View File

@@ -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 (
<Paper radius="md" withBorder className={classes.paper} mt={20}>
<ThemeIcon className={classes.icon} size={60} radius={60} bg={"#326ce5"}>
<ResourceIcon type={props.kubernetesCapacity.type} />
</ThemeIcon>
<Text ta="center" fw={700} className={classes.title}>
{props.kubernetesCapacity.type}
</Text>
{props.kubernetesCapacity.resourcesStats.map((stat) => {
const isReserved = stat.type === "Reserved";
const labelKey = isReserved
? "kubernetes.cluster.capacity.resource.reserved"
: "kubernetes.cluster.capacity.resource.used";
return (
<div key={stat.percentageValue}>
<Group justify={"space-between"} mt="xs">
<Text fz="sm" c="dimmed">
{isRTL ? (
<>
{stat.capacityUnit && (
<Text component="span" mr={4}>
{stat.capacityUnit}
</Text>
)}
<Text component="span">
{stat.maxUsedValue} / {stat.usedValue}{" "}
</Text>
<Text component="span" fw={500}>
{t(labelKey)}
</Text>
</>
) : (
<>
<Text component="span" fw={500}>
{t(labelKey)}
</Text>
<Text component="span">
{" "}
{stat.usedValue} / {stat.maxUsedValue}{" "}
</Text>
{stat.capacityUnit && (
<Text component="span" ml={4}>
{stat.capacityUnit}
</Text>
)}
</>
)}
</Text>
<Text fz="sm" c="dimmed">
{isRTL ? `%${stat.percentageValue}` : `${stat.percentageValue}%`}
</Text>
</Group>
<Progress value={stat.percentageValue} mt={5} color={getProgressBarColor(stat.percentageValue)} />
</div>
);
})}
</Paper>
);
}
function getProgressBarColor(value: number): string {
if (value > 50 && value < 65) {
return "yellow";
} else if (value >= 65) {
return "red";
}
return "blue";
}

View File

@@ -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<KubernetesCapacityType, React.ComponentType<{ size: number; stroke: number }>>;
interface ResourceIconProps {
type: KubernetesCapacityType;
size?: number;
stroke?: number;
}
export const ResourceIcon: React.FC<ResourceIconProps> = ({ type, size = 32, stroke = 1.5 }) => {
const Icon = resourceIcons[type];
return <Icon size={size} stroke={stroke} />;
};

View File

@@ -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);
}
}

View File

@@ -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 (
<Card
withBorder
component={Link}
href={`/manage/tools/kubernetes/${props.label}`}
className={classes.cardContainer}
>
<Group justify="space-between" wrap="nowrap">
<Image src={`/images/kubernetes/${props.label}.svg`} alt={props.label} width={64} height={64} />
<Group gap="xs">
<Text size="xl" fw={700} tt="capitalize">
{props.count} {t(`kubernetes.cluster.resources.${props.label}`)}
</Text>
<IconArrowRight />
</Group>
</Group>
</Card>
);
}

View File

@@ -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<KubernetesBaseResource>[] => [
{
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 <MantineReactTable table={table} />;
}

View File

@@ -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 (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tConfigMaps("label")}</Title>
<ConfigmapsTable initialConfigMaps={configMaps} />
</Stack>
</>
);
}

View File

@@ -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<KubernetesIngress>[] => [
{
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) => (
<div key={ruleAndPaths.host}>
<Flex align="flex-end">
<Anchor href={getAbsoluteUrl(ruleAndPaths.host)} target="_blank">
{getAbsoluteUrl(ruleAndPaths.host)}
</Anchor>
<IconArrowRight size={22} stroke={2} />
</Flex>
{ruleAndPaths.paths.map((path) => (
<div key={createId()}>
{path.serviceName}:{path.port}
</div>
))}
</div>
))}
</>
);
},
},
{
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 <MantineReactTable table={table} />;
}

View File

@@ -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 (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tIngresses("label")}</Title>
<IngressesTable initialIngresses={ingresses} />
</Stack>
</>
);
}

View File

@@ -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<KubernetesNamespace>[] => [
{
accessorKey: "status",
header: t("field.state.label"),
Cell({ cell }) {
const checkIcon = <IconCircleDashedCheck style={{ width: rem(12), height: rem(12) }} />;
const downIcon = <IconHeartBroken style={{ width: rem(12), height: rem(12) }} />;
const badgeKubernetesNamespaceStatusColor = cell.row.original.status === "Active" ? "green" : "yellow";
const badgeKubernetesNamespaceStatusIcon = cell.row.original.status === "Active" ? checkIcon : downIcon;
return (
<Badge
leftSection={badgeKubernetesNamespaceStatusIcon}
color={badgeKubernetesNamespaceStatusColor}
variant="light"
>
{cell.row.original.status}
</Badge>
);
},
},
{
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 <MantineReactTable table={table} />;
}

View File

@@ -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 (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tNamespaces("label")}</Title>
<NamespacesTable initialNamespaces={namespaces} />
</Stack>
</>
);
}

View File

@@ -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<KubernetesNode>[] => [
{
accessorKey: "status",
header: t("field.state.label"),
Cell({ cell }) {
const checkIcon = <IconCircleDashedCheck style={{ width: rem(12), height: rem(12) }} />;
const downIcon = <IconHeartBroken style={{ width: rem(12), height: rem(12) }} />;
const badgeKubernetesNodeStatusColor = cell.row.original.status === "Ready" ? "green" : "red";
const badgeKubernetesNodeStatusIcon = cell.row.original.status === "Ready" ? checkIcon : downIcon;
return (
<Badge leftSection={badgeKubernetesNodeStatusIcon} color={badgeKubernetesNodeStatusColor} variant="light">
{cell.row.original.status}
</Badge>
);
},
},
{
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 <MantineReactTable table={table} />;
}
function getRingProgress(value: number) {
return (
<RingProgress
size={70}
roundCaps
thickness={7}
sections={[{ value, color: "blue" }]}
label={
<Text c="blue" fw={400} ta="center" size="md">
{value}%
</Text>
}
/>
);
}

View File

@@ -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 (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tNodes("label")}</Title>
<NodesTable initialNodes={nodes} />
</Stack>
</>
);
}

View File

@@ -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 (
<>
<DynamicBreadcrumb />
<ClusterDashboard />
</>
);
}

View File

@@ -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 (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tPods("label")}</Title>
<PodsTable initialPods={pods} />
</Stack>
</>
);
}

View File

@@ -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<KubernetesPod>[] => [
{
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 <MantineReactTable table={table} />;
}

View File

@@ -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 (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tSecrets("label")}</Title>
<SecretsTable initialSecrets={secrets} />
</Stack>
</>
);
}

View File

@@ -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<KubernetesSecret>[] => [
{
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 <MantineReactTable table={table} />;
}

View File

@@ -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 (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tServices("label")}</Title>
<ServicesTable initialServices={services} />
</Stack>
</>
);
}

View File

@@ -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<KubernetesService>[] => [
{
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) => <div key={createId()}>{port}</div>);
},
},
{
accessorKey: "targetPorts",
header: t("field.targetPorts.label"),
Cell({ cell }) {
return cell.row.original.targetPorts?.map((targetPort) => <div key={createId()}>{targetPort}</div>);
},
},
{
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 <MantineReactTable table={table} />;
}

View File

@@ -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 (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tVolumes("label")}</Title>
<VolumesTable initialVolumes={volumes} />
</Stack>
</>
);
}

View File

@@ -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<KubernetesVolume>[] => [
{
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) => <div key={accessMode}>{accessMode}</div>);
},
},
{
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 <MantineReactTable table={table} />;
}