Kubernetes support

* Total CPU and Memory usage for the entire cluster
* Total CPU and Memory usage for kubernetes pods
* Service discovery via annotations on ingress
* No storage stats yet
* No network stats yet
pull/448/head
James Wynn 2 years ago
parent b25ba09e18
commit c4333fd2dc

3
.gitignore vendored

@ -41,3 +41,6 @@ next-env.d.ts
# homepage
/config
# idea
.idea/

@ -11,6 +11,7 @@
},
"dependencies": {
"@headlessui/react": "^1.7.2",
"@kubernetes/client-node": "^0.17.1",
"classnames": "^2.3.2",
"compare-versions": "^5.0.1",
"dockerode": "^3.3.4",

@ -3,8 +3,10 @@ import { useContext, useState } from "react";
import Status from "./status";
import Widget from "./widget";
import KubernetesStatus from "./kubernetes-status";
import Docker from "widgets/docker/component";
import Kubernetes from "widgets/kubernetes/component";
import { SettingsContext } from "utils/contexts/settings";
import ResolvedIcon from "components/resolvedicon";
@ -80,6 +82,16 @@ export default function Item({ service }) {
<span className="sr-only">View container stats</span>
</button>
)}
{service.app && (
<button
type="button"
onClick={() => (statsOpen ? closeStats() : setStatsOpen(true))}
className="flex-shrink-0 flex items-center justify-center w-12 cursor-pointer"
>
<KubernetesStatus service={service} />
<span className="sr-only">View container stats</span>
</button>
)}
</div>
{service.container && service.server && (
@ -92,6 +104,16 @@ export default function Item({ service }) {
{statsOpen && <Docker service={{ widget: { container: service.container, server: service.server } }} />}
</div>
)}
{service.app && (
<div
className={classNames(
statsOpen && !statsClosing ? "max-h-[55px] opacity-100" : " max-h-[0] opacity-0",
"w-full overflow-hidden transition-all duration-300 ease-in-out"
)}
>
{statsOpen && <Kubernetes service={{ widget: { namespace: service.namespace, app: service.app } }} />}
</div>
)}
{service.widget && <Widget service={service} />}
</div>

@ -0,0 +1,19 @@
import useSWR from "swr";
export default function KubernetesStatus({ service }) {
const { data, error } = useSWR(`/api/kubernetes/status/${service.namespace}/${service.app}`);
if (error) {
return <div className="w-3 h-3 bg-rose-300 dark:bg-rose-500 rounded-full" />;
}
if (data && data.status === "running") {
return <div className="w-3 h-3 bg-emerald-300 dark:bg-emerald-500 rounded-full" />;
}
if (data && data.status === "not found") {
return <div className="h-2.5 w-2.5 bg-orange-400/50 dark:bg-yellow-200/40 -rotate-45" />;
}
return <div className="w-3 h-3 bg-black/20 dark:bg-white/40 rounded-full" />;
}

@ -5,10 +5,10 @@ import { useTranslation } from "next-i18next";
import UsageBar from "./usage-bar";
export default function Cpu({ expanded }) {
export default function Cpu({ expanded, backend }) {
const { t } = useTranslation();
const { data, error } = useSWR(`/api/widgets/resources?type=cpu`, {
const { data, error } = useSWR(`/api/widgets/${backend || 'resources'}?type=cpu`, {
refreshInterval: 1500,
});

@ -5,10 +5,10 @@ import { useTranslation } from "next-i18next";
import UsageBar from "./usage-bar";
export default function Disk({ options, expanded }) {
export default function Disk({ options, expanded, backend }) {
const { t } = useTranslation();
const { data, error } = useSWR(`/api/widgets/resources?type=disk&target=${options.disk}`, {
const { data, error } = useSWR(`/api/widgets/${backend || 'resources'}?type=disk&target=${options.disk}`, {
refreshInterval: 1500,
});

@ -5,10 +5,10 @@ import { useTranslation } from "next-i18next";
import UsageBar from "./usage-bar";
export default function Memory({ expanded }) {
export default function Memory({ expanded, backend }) {
const { t } = useTranslation();
const { data, error } = useSWR(`/api/widgets/resources?type=memory`, {
const { data, error } = useSWR(`/api/widgets/${backend || 'resources'}?type=memory`, {
refreshInterval: 1500,
});

@ -3,15 +3,15 @@ import Cpu from "./cpu";
import Memory from "./memory";
export default function Resources({ options }) {
const { expanded } = options;
const { expanded, backend } = options;
return (
<div className="flex flex-col max-w:full sm:basis-auto self-center grow-0 flex-wrap">
<div className="flex flex-row self-center flex-wrap justify-between">
{options.cpu && <Cpu expanded={expanded} />}
{options.memory && <Memory expanded={expanded} />}
{options.cpu && <Cpu expanded={expanded} backend={backend} />}
{options.memory && <Memory expanded={expanded} backend={backend} />}
{Array.isArray(options.disk)
? options.disk.map((disk) => <Disk key={disk} options={{ disk }} expanded={expanded} />)
: options.disk && <Disk options={options} expanded={expanded} />}
? options.disk.map((disk) => <Disk key={disk} options={{ disk }} expanded={expanded} backend={backend} />)
: options.disk && <Disk options={options} expanded={expanded} backend={backend} />}
</div>
{options.label && (
<div className="ml-6 pt-1 text-center text-theme-800 dark:text-theme-200 text-xs">{options.label}</div>

@ -0,0 +1,79 @@
import { CoreV1Api, Metrics } from "@kubernetes/client-node";
import getKubeConfig from "../../../../utils/config/kubernetes";
import { parseCpu, parseMemory } from "../../../../utils/kubernetes/kubernetes-utils";
export default async function handler(req, res) {
const APP_LABEL = "app.kubernetes.io/name";
const { service } = req.query;
const [namespace, appName] = service;
if (!namespace && !appName) {
res.status(400).send({
error: "kubernetes query parameters are required",
});
return;
}
const labelSelector = `${APP_LABEL}=${appName}`;
try {
const kc = getKubeConfig();
const coreApi = kc.makeApiClient(CoreV1Api);
const metricsApi = new Metrics(kc);
const podsResponse = await coreApi.listNamespacedPod(namespace, null, null, null, null, labelSelector);
const pods = podsResponse.body.items;
if (pods.length === 0) {
res.status(200).send({
error: "not found",
});
return;
}
let cpuLimit = 0;
let memLimit = 0;
pods.forEach((pod) => {
pod.spec.containers.forEach((container) => {
if (container?.resources?.limits?.cpu) {
cpuLimit += parseCpu(container?.resources?.limits?.cpu);
}
if (container?.resources?.limits?.memory) {
memLimit += parseMemory(container?.resources?.limits?.memory);
}
});
});
const stats = await pods.map(async (pod) => {
let depMem = 0;
let depCpu = 0;
const podMetrics = await metricsApi.getPodMetrics(namespace, pod.metadata.name);
podMetrics.containers.forEach((container) => {
depMem += parseMemory(container.usage.memory);
depCpu += parseCpu(container.usage.cpu);
});
return {
mem: depMem,
cpu: depCpu
}
}).reduce(async (finalStats, podStatPromise) => {
const podStats = await podStatPromise;
return {
mem: finalStats.mem + podStats.mem,
cpu: finalStats.cpu + podStats.cpu
};
});
stats.cpuLimit = cpuLimit;
stats.memLimit = memLimit;
stats.cpuUsage = stats.cpu / cpuLimit;
stats.memUsage = stats.mem / memLimit;
res.status(200).json({
stats,
});
} catch (e) {
console.log("error", e);
res.status(500).send({
error: "unknown error",
});
}
}

@ -0,0 +1,42 @@
import { CoreV1Api } from "@kubernetes/client-node";
import getKubeConfig from "../../../../utils/config/kubernetes";
export default async function handler(req, res) {
const APP_LABEL = "app.kubernetes.io/name";
const { service } = req.query;
const [namespace, appName] = service;
if (!namespace && !appName) {
res.status(400).send({
error: "kubernetes query parameters are required",
});
return;
}
const labelSelector = `${APP_LABEL}=${appName}`;
try {
const kc = getKubeConfig();
const coreApi = kc.makeApiClient(CoreV1Api);
const podsResponse = await coreApi.listNamespacedPod(namespace, null, null, null, null, labelSelector);
const pods = podsResponse.body.items;
if (pods.length === 0) {
res.status(200).send({
error: "not found",
});
return;
}
// at least one pod must be in the "Running" phase, otherwise its "down"
const runningPod = pods.find(pod => pod.status.phase === "Running");
const status = runningPod ? "running" : "down";
res.status(200).json({
status
});
} catch {
res.status(500).send({
error: "unknown error",
});
}
}

@ -0,0 +1,72 @@
import { CoreV1Api, Metrics } from "@kubernetes/client-node";
import getKubeConfig from "../../../utils/config/kubernetes";
import { parseCpu, parseMemory } from "../../../utils/kubernetes/kubernetes-utils";
export default async function handler(req, res) {
const { type } = req.query;
const kc = getKubeConfig();
const coreApi = kc.makeApiClient(CoreV1Api);
const metricsApi = new Metrics(kc);
const nodes = await coreApi.listNode();
const nodeCapacity = new Map();
let cpuTotal = 0;
let cpuUsage = 0;
let memTotal = 0;
let memUsage = 0;
nodes.body.items.forEach((node) => {
nodeCapacity.set(node.metadata.name, node.status.capacity);
cpuTotal += Number.parseInt(node.status.capacity.cpu, 10);
memTotal += parseMemory(node.status.capacity.memory);
});
const nodeMetrics = await metricsApi.getNodeMetrics();
const nodeUsage = new Map();
nodeMetrics.items.forEach((metrics) => {
nodeUsage.set(metrics.metadata.name, metrics.usage);
cpuUsage += parseCpu(metrics.usage.cpu);
memUsage += parseMemory(metrics.usage.memory);
});
if (type === "cpu") {
return res.status(200).json({
cpu: {
usage: (cpuUsage / cpuTotal) * 100,
load: cpuUsage
}
});
}
// Maybe Storage CSI can provide this information
// if (type === "disk") {
// if (!existsSync(target)) {
// return res.status(404).json({
// error: "Target not found",
// });
// }
//
// return res.status(200).json({
// drive: await drive.info(target || "/"),
// });
// }
//
if (type === "memory") {
const SCALE_MB = 1024 * 1024;
const usedMemMb = memUsage / SCALE_MB;
const totalMemMb = memTotal / SCALE_MB;
const freeMemMb = totalMemMb - usedMemMb;
return res.status(200).json({
memory: {
usedMemMb,
freeMemMb,
totalMemMb
}
});
}
return res.status(400).json({
error: "invalid type"
});
}

@ -0,0 +1,2 @@
---
# sample kubernetes config

@ -5,7 +5,12 @@ import path from "path";
import yaml from "js-yaml";
import checkAndCopyConfig from "utils/config/config";
import { servicesFromConfig, servicesFromDocker, cleanServiceGroups } from "utils/config/service-helpers";
import {
servicesFromConfig,
servicesFromDocker,
cleanServiceGroups,
servicesFromKubernetes
} from "utils/config/service-helpers";
import { cleanWidgetGroups, widgetsFromConfig } from "utils/config/widget-helpers";
export async function bookmarksResponse() {
@ -44,15 +49,24 @@ export async function widgetsResponse() {
}
export async function servicesResponse() {
let discoveredServices;
let discoveredDockerServices;
let discoveredKubernetesServices;
let configuredServices;
try {
discoveredServices = cleanServiceGroups(await servicesFromDocker());
discoveredDockerServices = cleanServiceGroups(await servicesFromDocker());
} catch (e) {
console.error("Failed to discover services, please check docker.yaml for errors or remove example entries.");
if (e) console.error(e);
discoveredServices = [];
discoveredDockerServices = [];
}
try {
discoveredKubernetesServices = cleanServiceGroups(await servicesFromKubernetes());
} catch (e) {
console.error("Failed to discover services, please check docker.yaml for errors or remove example entries.");
if (e) console.error(e);
discoveredKubernetesServices = [];
}
try {
@ -64,18 +78,27 @@ export async function servicesResponse() {
}
const mergedGroupsNames = [
...new Set([discoveredServices.map((group) => group.name), configuredServices.map((group) => group.name)].flat()),
...new Set([
discoveredDockerServices.map((group) => group.name),
discoveredKubernetesServices.map((group) => group.name),
configuredServices.map((group) => group.name),
].flat()),
];
const mergedGroups = [];
mergedGroupsNames.forEach((groupName) => {
const discoveredGroup = discoveredServices.find((group) => group.name === groupName) || { services: [] };
const discoveredDockerGroup = discoveredDockerServices.find((group) => group.name === groupName) || { services: [] };
const discoveredKubernetesGroup = discoveredKubernetesServices.find((group) => group.name === groupName) || { services: [] };
const configuredGroup = configuredServices.find((group) => group.name === groupName) || { services: [] };
const mergedGroup = {
name: groupName,
services: [...discoveredGroup.services, ...configuredGroup.services].filter((service) => service),
services: [
...discoveredDockerGroup.services,
...discoveredKubernetesGroup.services,
...configuredGroup.services
].filter((service) => service),
};
mergedGroups.push(mergedGroup);

@ -0,0 +1,27 @@
import path from "path";
import { readFileSync } from "fs";
import yaml from "js-yaml";
import { KubeConfig } from "@kubernetes/client-node";
import checkAndCopyConfig from "utils/config/config";
export default function getKubeConfig() {
checkAndCopyConfig("kubernetes.yaml");
const configFile = path.join(process.cwd(), "config", "kubernetes.yaml");
const configData = readFileSync(configFile, "utf8");
const config = yaml.load(configData);
const kc = new KubeConfig();
switch (config?.mode) {
case 'cluster':
kc.loadFromCluster();
break;
case 'default':
default:
kc.loadFromDefault();
}
return kc;
}

@ -4,9 +4,11 @@ import path from "path";
import yaml from "js-yaml";
import Docker from "dockerode";
import * as shvl from "shvl";
import { NetworkingV1Api } from "@kubernetes/client-node";
import checkAndCopyConfig from "utils/config/config";
import getDockerArguments from "utils/config/docker";
import getKubeConfig from "utils/config/kubernetes";
export async function servicesFromConfig() {
checkAndCopyConfig("services.yaml");
@ -103,6 +105,56 @@ export async function servicesFromDocker() {
return mappedServiceGroups;
}
export async function servicesFromKubernetes() {
checkAndCopyConfig("kubernetes.yaml");
const kc = getKubeConfig();
const networking = kc.makeApiClient(NetworkingV1Api);
const ingressResponse = await networking.listIngressForAllNamespaces(null, null, null, "homepage/enabled=true");
const services = ingressResponse.body.items.map((ingress) => {
const constructedService = {
app: ingress.metadata.name,
namespace: ingress.metadata.namespace,
href: `https://${ingress.spec.rules[0].host}`,
name: ingress.metadata.annotations['homepage/name'],
group: ingress.metadata.annotations['homepage/group'],
icon: ingress.metadata.annotations['homepage/icon'],
description: ingress.metadata.annotations['homepage/description']
};
Object.keys(ingress.metadata.labels).forEach((label) => {
if (label.startsWith("homepage/widget/")) {
shvl.set(constructedService, label.replace("homepage/widget/", ""), ingress.metadata.labels[label]);
}
});
return constructedService;
});
const mappedServiceGroups = [];
services.forEach((serverService) => {
let serverGroup = mappedServiceGroups.find((searchedGroup) => searchedGroup.name === serverService.group);
if (!serverGroup) {
mappedServiceGroups.push({
name: serverService.group,
services: [],
});
serverGroup = mappedServiceGroups[mappedServiceGroups.length - 1];
}
const { name: serviceName, group: serverServiceGroup, ...pushedService } = serverService;
const result = {
name: serviceName,
...pushedService,
};
serverGroup.services.push(result);
});
return mappedServiceGroups;
}
export function cleanServiceGroups(groups) {
return groups.map((serviceGroup) => ({
name: serviceGroup.name,
@ -118,6 +170,8 @@ export function cleanServiceGroups(groups) {
container,
currency, // coinmarketcap widget
symbols,
namespace, // kubernetes widget
app
} = cleanedService.widget;
cleanedService.widget = {
@ -134,6 +188,10 @@ export function cleanServiceGroups(groups) {
if (server) cleanedService.widget.server = server;
if (container) cleanedService.widget.container = container;
}
if (type === "kubernetes") {
if (namespace) cleanedService.widget.namespace = namespace;
if (app) cleanedService.widget.app = app;
}
}
return cleanedService;
@ -164,5 +222,15 @@ export default async function getServiceWidget(group, service) {
}
}
const kubernetesServices = await servicesFromKubernetes();
const kubernetesServiceGroup = kubernetesServices.find((g) => g.name === group);
if (kubernetesServiceGroup) {
const kubernetesServiceEntry = kubernetesServiceGroup.services.find((s) => s.name === service);
if (kubernetesServiceEntry) {
const { widget } = kubernetesServiceEntry;
return widget;
}
}
return false;
}
}

@ -0,0 +1,47 @@
export function parseCpu(cpuStr) {
const unitLength = 1;
const base = Number.parseInt(cpuStr, 10);
const units = cpuStr.substring(cpuStr.length - unitLength);
// console.log(Number.isNaN(Number(units)), cpuStr, base, units);
if (Number.isNaN(Number(units))) {
switch (units) {
case 'n':
return base / 1000000000;
case 'u':
return base / 1000000;
case 'm':
return base / 1000;
default:
return base;
}
} else {
return Number.parseInt(cpuStr, 10);
}
}
export function parseMemory(memStr) {
const unitLength = (memStr.substring(memStr.length - 1) === 'i' ? 2 : 1);
const base = Number.parseInt(memStr, 10);
const units = memStr.substring(memStr.length - unitLength);
// console.log(Number.isNaN(Number(units)), memStr, base, units);
if (Number.isNaN(Number(units))) {
switch (units) {
case 'Ki':
return base * 1000;
case 'K':
return base * 1024;
case 'Mi':
return base * 1000000;
case 'M':
return base * 1024 * 1024;
case 'Gi':
return base * 1000000000;
case 'G':
return base * 1024 * 1024 * 1024;
default:
return base;
}
} else {
return Number.parseInt(memStr, 10);
}
}

@ -8,6 +8,7 @@ const components = {
changedetectionio: dynamic(() => import("./changedetectionio/component")),
coinmarketcap: dynamic(() => import("./coinmarketcap/component")),
docker: dynamic(() => import("./docker/component")),
kubernetes: dynamic(() => import("./kubernetes/component")),
emby: dynamic(() => import("./emby/component")),
gotify: dynamic(() => import("./gotify/component")),
homebridge: dynamic(() => import("./homebridge/component")),

@ -0,0 +1,54 @@
import useSWR from "swr";
import { useTranslation } from "next-i18next";
import Container from "components/services/widget/container";
import Block from "components/services/widget/block";
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { data: statusData, error: statusError } = useSWR(
`/api/kubernetes/status/${widget.namespace}/${widget.app}`);
const { data: statsData, error: statsError } = useSWR(
`/api/kubernetes/stats/${widget.namespace}/${widget.app}`);
if (statsError || statusError) {
return <Container error={t("widget.api_error")} />;
}
if (statusData && statusData.status !== "running") {
return (
<Container>
<Block label={t("widget.status")} value={t("docker.offline")} />
</Container>
);
}
if (!statsData || !statusData) {
return (
<Container service={service}>
<Block label="docker.cpu" />
<Block label="docker.mem" />
<Block label="docker.rx" />
<Block label="docker.tx" />
</Container>
);
}
const network = statsData.stats?.networks?.eth0 || statsData.stats?.networks?.network;
return (
<Container service={service}>
<Block label="docker.cpu" value={t("common.percent", { value: statsData.stats.cpuUsage })} />
<Block label="docker.mem" value={t("common.bytes", { value: statsData.stats.mem })} />
{network && (
<>
<Block label="docker.rx" value={t("common.bytes", { value: network.rx_bytes })} />
<Block label="docker.tx" value={t("common.bytes", { value: network.tx_bytes })} />
</>
)}
</Container>
);
}
Loading…
Cancel
Save