mirror of
https://github.com/karl0ss/homepage.git
synced 2025-04-29 12:03:41 +01:00
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
This commit is contained in:
parent
b25ba09e18
commit
c4333fd2dc
3
.gitignore
vendored
3
.gitignore
vendored
@ -41,3 +41,6 @@ next-env.d.ts
|
|||||||
|
|
||||||
# homepage
|
# homepage
|
||||||
/config
|
/config
|
||||||
|
|
||||||
|
# idea
|
||||||
|
.idea/
|
||||||
|
@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^1.7.2",
|
"@headlessui/react": "^1.7.2",
|
||||||
|
"@kubernetes/client-node": "^0.17.1",
|
||||||
"classnames": "^2.3.2",
|
"classnames": "^2.3.2",
|
||||||
"compare-versions": "^5.0.1",
|
"compare-versions": "^5.0.1",
|
||||||
"dockerode": "^3.3.4",
|
"dockerode": "^3.3.4",
|
||||||
|
@ -3,8 +3,10 @@ import { useContext, useState } from "react";
|
|||||||
|
|
||||||
import Status from "./status";
|
import Status from "./status";
|
||||||
import Widget from "./widget";
|
import Widget from "./widget";
|
||||||
|
import KubernetesStatus from "./kubernetes-status";
|
||||||
|
|
||||||
import Docker from "widgets/docker/component";
|
import Docker from "widgets/docker/component";
|
||||||
|
import Kubernetes from "widgets/kubernetes/component";
|
||||||
import { SettingsContext } from "utils/contexts/settings";
|
import { SettingsContext } from "utils/contexts/settings";
|
||||||
import ResolvedIcon from "components/resolvedicon";
|
import ResolvedIcon from "components/resolvedicon";
|
||||||
|
|
||||||
@ -80,6 +82,16 @@ export default function Item({ service }) {
|
|||||||
<span className="sr-only">View container stats</span>
|
<span className="sr-only">View container stats</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{service.container && service.server && (
|
{service.container && service.server && (
|
||||||
@ -92,6 +104,16 @@ export default function Item({ service }) {
|
|||||||
{statsOpen && <Docker service={{ widget: { container: service.container, server: service.server } }} />}
|
{statsOpen && <Docker service={{ widget: { container: service.container, server: service.server } }} />}
|
||||||
</div>
|
</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} />}
|
{service.widget && <Widget service={service} />}
|
||||||
</div>
|
</div>
|
||||||
|
19
src/components/services/kubernetes-status.jsx
Normal file
19
src/components/services/kubernetes-status.jsx
Normal file
@ -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";
|
import UsageBar from "./usage-bar";
|
||||||
|
|
||||||
export default function Cpu({ expanded }) {
|
export default function Cpu({ expanded, backend }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data, error } = useSWR(`/api/widgets/resources?type=cpu`, {
|
const { data, error } = useSWR(`/api/widgets/${backend || 'resources'}?type=cpu`, {
|
||||||
refreshInterval: 1500,
|
refreshInterval: 1500,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -5,10 +5,10 @@ import { useTranslation } from "next-i18next";
|
|||||||
|
|
||||||
import UsageBar from "./usage-bar";
|
import UsageBar from "./usage-bar";
|
||||||
|
|
||||||
export default function Disk({ options, expanded }) {
|
export default function Disk({ options, expanded, backend }) {
|
||||||
const { t } = useTranslation();
|
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,
|
refreshInterval: 1500,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -5,10 +5,10 @@ import { useTranslation } from "next-i18next";
|
|||||||
|
|
||||||
import UsageBar from "./usage-bar";
|
import UsageBar from "./usage-bar";
|
||||||
|
|
||||||
export default function Memory({ expanded }) {
|
export default function Memory({ expanded, backend }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data, error } = useSWR(`/api/widgets/resources?type=memory`, {
|
const { data, error } = useSWR(`/api/widgets/${backend || 'resources'}?type=memory`, {
|
||||||
refreshInterval: 1500,
|
refreshInterval: 1500,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -3,15 +3,15 @@ import Cpu from "./cpu";
|
|||||||
import Memory from "./memory";
|
import Memory from "./memory";
|
||||||
|
|
||||||
export default function Resources({ options }) {
|
export default function Resources({ options }) {
|
||||||
const { expanded } = options;
|
const { expanded, backend } = options;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col max-w:full sm:basis-auto self-center grow-0 flex-wrap">
|
<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">
|
<div className="flex flex-row self-center flex-wrap justify-between">
|
||||||
{options.cpu && <Cpu expanded={expanded} />}
|
{options.cpu && <Cpu expanded={expanded} backend={backend} />}
|
||||||
{options.memory && <Memory expanded={expanded} />}
|
{options.memory && <Memory expanded={expanded} backend={backend} />}
|
||||||
{Array.isArray(options.disk)
|
{Array.isArray(options.disk)
|
||||||
? options.disk.map((disk) => <Disk key={disk} options={{ disk }} expanded={expanded} />)
|
? options.disk.map((disk) => <Disk key={disk} options={{ disk }} expanded={expanded} backend={backend} />)
|
||||||
: options.disk && <Disk options={options} expanded={expanded} />}
|
: options.disk && <Disk options={options} expanded={expanded} backend={backend} />}
|
||||||
</div>
|
</div>
|
||||||
{options.label && (
|
{options.label && (
|
||||||
<div className="ml-6 pt-1 text-center text-theme-800 dark:text-theme-200 text-xs">{options.label}</div>
|
<div className="ml-6 pt-1 text-center text-theme-800 dark:text-theme-200 text-xs">{options.label}</div>
|
||||||
|
79
src/pages/api/kubernetes/stats/[...service].js
Normal file
79
src/pages/api/kubernetes/stats/[...service].js
Normal file
@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
42
src/pages/api/kubernetes/status/[...service].js
Normal file
42
src/pages/api/kubernetes/status/[...service].js
Normal file
@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
72
src/pages/api/widgets/kubernetes.js
Normal file
72
src/pages/api/widgets/kubernetes.js
Normal file
@ -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"
|
||||||
|
});
|
||||||
|
}
|
2
src/skeleton/kubernetes.yaml
Normal file
2
src/skeleton/kubernetes.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
---
|
||||||
|
# sample kubernetes config
|
@ -5,7 +5,12 @@ import path from "path";
|
|||||||
import yaml from "js-yaml";
|
import yaml from "js-yaml";
|
||||||
|
|
||||||
import checkAndCopyConfig from "utils/config/config";
|
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";
|
import { cleanWidgetGroups, widgetsFromConfig } from "utils/config/widget-helpers";
|
||||||
|
|
||||||
export async function bookmarksResponse() {
|
export async function bookmarksResponse() {
|
||||||
@ -44,15 +49,24 @@ export async function widgetsResponse() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function servicesResponse() {
|
export async function servicesResponse() {
|
||||||
let discoveredServices;
|
let discoveredDockerServices;
|
||||||
|
let discoveredKubernetesServices;
|
||||||
let configuredServices;
|
let configuredServices;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
discoveredServices = cleanServiceGroups(await servicesFromDocker());
|
discoveredDockerServices = cleanServiceGroups(await servicesFromDocker());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to discover services, please check docker.yaml for errors or remove example entries.");
|
console.error("Failed to discover services, please check docker.yaml for errors or remove example entries.");
|
||||||
if (e) console.error(e);
|
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 {
|
try {
|
||||||
@ -64,18 +78,27 @@ export async function servicesResponse() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mergedGroupsNames = [
|
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 = [];
|
const mergedGroups = [];
|
||||||
|
|
||||||
mergedGroupsNames.forEach((groupName) => {
|
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 configuredGroup = configuredServices.find((group) => group.name === groupName) || { services: [] };
|
||||||
|
|
||||||
const mergedGroup = {
|
const mergedGroup = {
|
||||||
name: groupName,
|
name: groupName,
|
||||||
services: [...discoveredGroup.services, ...configuredGroup.services].filter((service) => service),
|
services: [
|
||||||
|
...discoveredDockerGroup.services,
|
||||||
|
...discoveredKubernetesGroup.services,
|
||||||
|
...configuredGroup.services
|
||||||
|
].filter((service) => service),
|
||||||
};
|
};
|
||||||
|
|
||||||
mergedGroups.push(mergedGroup);
|
mergedGroups.push(mergedGroup);
|
||||||
|
27
src/utils/config/kubernetes.js
Normal file
27
src/utils/config/kubernetes.js
Normal file
@ -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 yaml from "js-yaml";
|
||||||
import Docker from "dockerode";
|
import Docker from "dockerode";
|
||||||
import * as shvl from "shvl";
|
import * as shvl from "shvl";
|
||||||
|
import { NetworkingV1Api } from "@kubernetes/client-node";
|
||||||
|
|
||||||
import checkAndCopyConfig from "utils/config/config";
|
import checkAndCopyConfig from "utils/config/config";
|
||||||
import getDockerArguments from "utils/config/docker";
|
import getDockerArguments from "utils/config/docker";
|
||||||
|
import getKubeConfig from "utils/config/kubernetes";
|
||||||
|
|
||||||
export async function servicesFromConfig() {
|
export async function servicesFromConfig() {
|
||||||
checkAndCopyConfig("services.yaml");
|
checkAndCopyConfig("services.yaml");
|
||||||
@ -103,6 +105,56 @@ export async function servicesFromDocker() {
|
|||||||
return mappedServiceGroups;
|
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) {
|
export function cleanServiceGroups(groups) {
|
||||||
return groups.map((serviceGroup) => ({
|
return groups.map((serviceGroup) => ({
|
||||||
name: serviceGroup.name,
|
name: serviceGroup.name,
|
||||||
@ -118,6 +170,8 @@ export function cleanServiceGroups(groups) {
|
|||||||
container,
|
container,
|
||||||
currency, // coinmarketcap widget
|
currency, // coinmarketcap widget
|
||||||
symbols,
|
symbols,
|
||||||
|
namespace, // kubernetes widget
|
||||||
|
app
|
||||||
} = cleanedService.widget;
|
} = cleanedService.widget;
|
||||||
|
|
||||||
cleanedService.widget = {
|
cleanedService.widget = {
|
||||||
@ -134,6 +188,10 @@ export function cleanServiceGroups(groups) {
|
|||||||
if (server) cleanedService.widget.server = server;
|
if (server) cleanedService.widget.server = server;
|
||||||
if (container) cleanedService.widget.container = container;
|
if (container) cleanedService.widget.container = container;
|
||||||
}
|
}
|
||||||
|
if (type === "kubernetes") {
|
||||||
|
if (namespace) cleanedService.widget.namespace = namespace;
|
||||||
|
if (app) cleanedService.widget.app = app;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleanedService;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
47
src/utils/kubernetes/kubernetes-utils.js
Normal file
47
src/utils/kubernetes/kubernetes-utils.js
Normal file
@ -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")),
|
changedetectionio: dynamic(() => import("./changedetectionio/component")),
|
||||||
coinmarketcap: dynamic(() => import("./coinmarketcap/component")),
|
coinmarketcap: dynamic(() => import("./coinmarketcap/component")),
|
||||||
docker: dynamic(() => import("./docker/component")),
|
docker: dynamic(() => import("./docker/component")),
|
||||||
|
kubernetes: dynamic(() => import("./kubernetes/component")),
|
||||||
emby: dynamic(() => import("./emby/component")),
|
emby: dynamic(() => import("./emby/component")),
|
||||||
gotify: dynamic(() => import("./gotify/component")),
|
gotify: dynamic(() => import("./gotify/component")),
|
||||||
homebridge: dynamic(() => import("./homebridge/component")),
|
homebridge: dynamic(() => import("./homebridge/component")),
|
||||||
|
54
src/widgets/kubernetes/component.jsx
Normal file
54
src/widgets/kubernetes/component.jsx
Normal file
@ -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…
x
Reference in New Issue
Block a user