Added gateway-api functionality.

This commit is contained in:
lyons 2025-01-23 19:55:33 +00:00
parent 4a3a4c846e
commit 8affc743fd
8 changed files with 488 additions and 267 deletions

View File

@ -8,6 +8,7 @@ The Kubernetes connectivity has the following requirements:
- Kubernetes 1.19+ - Kubernetes 1.19+
- Metrics Service - Metrics Service
- An Ingress controller - An Ingress controller
- Optionally: Gateway-API
The Kubernetes connection is configured in the `kubernetes.yaml` file. There are 3 modes to choose from: The Kubernetes connection is configured in the `kubernetes.yaml` file. There are 3 modes to choose from:
@ -19,6 +20,12 @@ The Kubernetes connection is configured in the `kubernetes.yaml` file. There are
mode: default mode: default
``` ```
To enable Kubernetes gateway-api compatibility, add the following setting:
```yaml
route: gateway
```
## Services ## Services
Once the Kubernetes connection is configured, individual services can be configured to pull statistics. Only CPU and Memory are currently supported. Once the Kubernetes connection is configured, individual services can be configured to pull statistics. Only CPU and Memory are currently supported.
@ -100,6 +107,8 @@ If you are using multiple instances of homepage, an `instance` annotation can be
If you have a single service that needs to be shown on multiple specific instances of homepage (but not on all of them), the service can be annotated by multiple `instance.name` annotations, where `name` can be the names of your specific multiple homepage instances. For example, a service that is annotated with `gethomepage.dev/instance.public: ""` and `gethomepage.dev/instance.internal: ""` will be shown on `public` and `internal` homepage instances. If you have a single service that needs to be shown on multiple specific instances of homepage (but not on all of them), the service can be annotated by multiple `instance.name` annotations, where `name` can be the names of your specific multiple homepage instances. For example, a service that is annotated with `gethomepage.dev/instance.public: ""` and `gethomepage.dev/instance.internal: ""` will be shown on `public` and `internal` homepage instances.
Use the `gethomepage.dev/pod-selector` selector to specify the pod used for the health check. For example, a service that is annotated with `gethomepage.dev/pod-selector: app.kubernetes.io/name=deployment` would link to a pod with the label `app.kubernetes.io/name: deployment`.
### Traefik IngressRoute support ### Traefik IngressRoute support
Homepage can also read ingresses defined using the Traefik IngressRoute custom resource definition. Due to the complex nature of Traefik routing rules, it is required for the `gethomepage.dev/href` annotation to be set: Homepage can also read ingresses defined using the Traefik IngressRoute custom resource definition. Due to the complex nature of Traefik routing rules, it is required for the `gethomepage.dev/href` annotation to be set:
@ -140,6 +149,10 @@ spec:
If the `href` attribute is not present, Homepage will ignore the specific IngressRoute. If the `href` attribute is not present, Homepage will ignore the specific IngressRoute.
### Gateway API HttpRoute support
Homepage also features automatic service discovery for gateway-api. Service definitions are read by annotating the HttpRoute custom resource definition and are indentical to the Ingress example as defined in [Automatic Service Discovery](#automatic-service-discovery).
## Caveats ## Caveats
Similarly to Docker service discovery, there currently is no rigid ordering to discovered services and discovered services will be displayed above those specified in the `services.yaml`. Similarly to Docker service discovery, there currently is no rigid ordering to discovered services and discovered services will be displayed above those specified in the `services.yaml`.

View File

@ -215,6 +215,15 @@ rules:
verbs: verbs:
- get - get
- list - list
# if using gateway api add the following:
# - apiGroups:
# - gateway.networking.k8s.io
# resources:
# - httproutes
# - gateways
# verbs:
# - get
# - list
- apiGroups: - apiGroups:
- metrics.k8s.io - metrics.k8s.io
resources: resources:
@ -370,7 +379,7 @@ prevent unnecessary re-renders on page loads and window / tab focusing. The
procedure for enabling sticky sessions depends on your Ingress controller. Below procedure for enabling sticky sessions depends on your Ingress controller. Below
is an example using Traefik as the Ingress controller. is an example using Traefik as the Ingress controller.
``` ```yaml
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
metadata: metadata:

View File

@ -1,6 +1,6 @@
import { CoreV1Api, Metrics } from "@kubernetes/client-node"; import { CoreV1Api, Metrics } from "@kubernetes/client-node";
import getKubeConfig from "../../../../utils/config/kubernetes"; import getKubeArguments from "../../../../utils/config/kubernetes";
import { parseCpu, parseMemory } from "../../../../utils/kubernetes/kubernetes-utils"; import { parseCpu, parseMemory } from "../../../../utils/kubernetes/kubernetes-utils";
import createLogger from "../../../../utils/logger"; import createLogger from "../../../../utils/logger";
@ -20,7 +20,7 @@ export default async function handler(req, res) {
const labelSelector = podSelector !== undefined ? podSelector : `${APP_LABEL}=${appName}`; const labelSelector = podSelector !== undefined ? podSelector : `${APP_LABEL}=${appName}`;
try { try {
const kc = getKubeConfig(); const kc = getKubeArguments().config;
if (!kc) { if (!kc) {
res.status(500).send({ res.status(500).send({
error: "No kubernetes configuration", error: "No kubernetes configuration",

View File

@ -1,6 +1,6 @@
import { CoreV1Api } from "@kubernetes/client-node"; import { CoreV1Api } from "@kubernetes/client-node";
import getKubeConfig from "../../../../utils/config/kubernetes"; import getKubeArguments from "../../../../utils/config/kubernetes";
import createLogger from "../../../../utils/logger"; import createLogger from "../../../../utils/logger";
const logger = createLogger("kubernetesStatusService"); const logger = createLogger("kubernetesStatusService");
@ -18,7 +18,7 @@ export default async function handler(req, res) {
} }
const labelSelector = podSelector !== undefined ? podSelector : `${APP_LABEL}=${appName}`; const labelSelector = podSelector !== undefined ? podSelector : `${APP_LABEL}=${appName}`;
try { try {
const kc = getKubeConfig(); const kc = getKubeArguments().config;
if (!kc) { if (!kc) {
res.status(500).send({ res.status(500).send({
error: "No kubernetes configuration", error: "No kubernetes configuration",

View File

@ -1,6 +1,6 @@
import { CoreV1Api, Metrics } from "@kubernetes/client-node"; import { CoreV1Api, Metrics } from "@kubernetes/client-node";
import getKubeConfig from "../../../utils/config/kubernetes"; import getKubeArguments from "../../../utils/config/kubernetes";
import { parseCpu, parseMemory } from "../../../utils/kubernetes/kubernetes-utils"; import { parseCpu, parseMemory } from "../../../utils/kubernetes/kubernetes-utils";
import createLogger from "../../../utils/logger"; import createLogger from "../../../utils/logger";
@ -8,7 +8,7 @@ const logger = createLogger("kubernetes-widget");
export default async function handler(req, res) { export default async function handler(req, res) {
try { try {
const kc = getKubeConfig(); const kc = getKubeArguments().config;
if (!kc) { if (!kc) {
return res.status(500).send({ return res.status(500).send({
error: "No kubernetes configuration", error: "No kubernetes configuration",

View File

@ -6,26 +6,50 @@ import { KubeConfig } from "@kubernetes/client-node";
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config"; import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
export default function getKubeConfig() { const extractKubeData = (config) => {
// kubeconfig
const kc = new KubeConfig();
kc.loadFromCluster();
// route
let route = "ingress";
if (config?.route === "gateway") {
route = "gateway";
}
// traefik
let traefik = true;
if (config?.traefik === "disable") {
traefik = false;
}
return {
config: kc,
route,
traefik,
};
};
export default function getKubeArguments() {
checkAndCopyConfig("kubernetes.yaml"); checkAndCopyConfig("kubernetes.yaml");
const configFile = path.join(CONF_DIR, "kubernetes.yaml"); const configFile = path.join(CONF_DIR, "kubernetes.yaml");
const rawConfigData = readFileSync(configFile, "utf8"); const rawConfigData = readFileSync(configFile, "utf8");
const configData = substituteEnvironmentVars(rawConfigData); const configData = substituteEnvironmentVars(rawConfigData);
const config = yaml.load(configData); const config = yaml.load(configData);
const kc = new KubeConfig(); let kubeData;
switch (config?.mode) { switch (config?.mode) {
case "cluster": case "cluster":
kc.loadFromCluster(); kubeData = extractKubeData(config);
break; break;
case "default": case "default":
kc.loadFromDefault(); kubeData = extractKubeData(config);
break; break;
case "disabled": case "disabled":
default: default:
return null; kubeData = { config: null };
} }
return kc; return kubeData;
} }

View File

@ -3,16 +3,51 @@ import path from "path";
import yaml from "js-yaml"; import yaml from "js-yaml";
import Docker from "dockerode"; import Docker from "dockerode";
import { CustomObjectsApi, NetworkingV1Api, ApiextensionsV1Api } from "@kubernetes/client-node";
import createLogger from "utils/logger"; import createLogger from "utils/logger";
import checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from "utils/config/config"; import checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from "utils/config/config";
import getDockerArguments from "utils/config/docker"; import getDockerArguments from "utils/config/docker";
import getKubeConfig from "utils/config/kubernetes"; import { getUrlSchema, getRouteList } from "utils/kubernetes/kubernetes-routes";
import * as shvl from "utils/config/shvl"; import * as shvl from "utils/config/shvl";
const logger = createLogger("service-helpers"); const logger = createLogger("service-helpers");
function parseServicesToGroups(services) {
if (!services) {
return [];
}
// map easy to write YAML objects into easy to consume JS arrays
return services.map((serviceGroup) => {
const name = Object.keys(serviceGroup)[0];
let groups = [];
const serviceGroupServices = [];
serviceGroup[name].forEach((entries) => {
const entryName = Object.keys(entries)[0];
if (!entries[entryName]) {
logger.warn(`Error parsing service "${entryName}" from config. Ensure required fields are present.`);
return;
}
if (Array.isArray(entries[entryName])) {
groups = groups.concat(parseServicesToGroups([{ [entryName]: entries[entryName] }]));
} else {
serviceGroupServices.push({
name: entryName,
...entries[entryName],
weight: entries[entryName].weight || serviceGroupServices.length * 100, // default weight
type: "service",
});
}
});
return {
name,
type: "group",
services: serviceGroupServices,
groups,
};
});
}
export async function servicesFromConfig() { export async function servicesFromConfig() {
checkAndCopyConfig("services.yaml"); checkAndCopyConfig("services.yaml");
@ -20,31 +55,7 @@ export async function servicesFromConfig() {
const rawFileContents = await fs.readFile(servicesYaml, "utf8"); const rawFileContents = await fs.readFile(servicesYaml, "utf8");
const fileContents = substituteEnvironmentVars(rawFileContents); const fileContents = substituteEnvironmentVars(rawFileContents);
const services = yaml.load(fileContents); const services = yaml.load(fileContents);
return parseServicesToGroups(services);
if (!services) {
return [];
}
// map easy to write YAML objects into easy to consume JS arrays
const servicesArray = services.map((servicesGroup) => ({
name: Object.keys(servicesGroup)[0],
services: servicesGroup[Object.keys(servicesGroup)[0]].map((entries) => ({
name: Object.keys(entries)[0],
...entries[Object.keys(entries)[0]],
type: "service",
})),
}));
// add default weight to services based on their position in the configuration
servicesArray.forEach((group, groupIndex) => {
group.services.forEach((service, serviceIndex) => {
if (service.weight === undefined) {
servicesArray[groupIndex].services[serviceIndex].weight = (serviceIndex + 1) * 100;
}
});
});
return servicesArray;
} }
export async function servicesFromDocker() { export async function servicesFromDocker() {
@ -98,7 +109,11 @@ export async function servicesFromDocker() {
type: "service", type: "service",
}; };
} }
shvl.set(constructedService, value, substituteEnvironmentVars(containerLabels[label])); let substitutedVal = substituteEnvironmentVars(containerLabels[label]);
if (value === "widget.version") {
substitutedVal = parseInt(substitutedVal, 10);
}
shvl.set(constructedService, value, substitutedVal);
} }
}); });
@ -151,33 +166,6 @@ export async function servicesFromDocker() {
return mappedServiceGroups; return mappedServiceGroups;
} }
function getUrlFromIngress(ingress) {
const urlHost = ingress.spec.rules[0].host;
const urlPath = ingress.spec.rules[0].http.paths[0].path;
const urlSchema = ingress.spec.tls ? "https" : "http";
return `${urlSchema}://${urlHost}${urlPath}`;
}
export async function checkCRD(kc, name) {
const apiExtensions = kc.makeApiClient(ApiextensionsV1Api);
const exist = await apiExtensions
.readCustomResourceDefinitionStatus(name)
.then(() => true)
.catch(async (error) => {
if (error.statusCode === 403) {
logger.error(
"Error checking if CRD %s exists. Make sure to add the following permission to your RBAC: %d %s %s",
name,
error.statusCode,
error.body.message,
);
}
return false;
});
return exist;
}
export async function servicesFromKubernetes() { export async function servicesFromKubernetes() {
const ANNOTATION_BASE = "gethomepage.dev"; const ANNOTATION_BASE = "gethomepage.dev";
const ANNOTATION_WIDGET_BASE = `${ANNOTATION_BASE}/widget.`; const ANNOTATION_WIDGET_BASE = `${ANNOTATION_BASE}/widget.`;
@ -186,128 +174,70 @@ export async function servicesFromKubernetes() {
checkAndCopyConfig("kubernetes.yaml"); checkAndCopyConfig("kubernetes.yaml");
try { try {
const kc = getKubeConfig(); const routeList = await getRouteList(ANNOTATION_BASE);
if (!kc) {
if (!routeList) {
return []; return [];
} }
const networking = kc.makeApiClient(NetworkingV1Api);
const crd = kc.makeApiClient(CustomObjectsApi);
const ingressList = await networking const services = await Promise.all(
.listIngressForAllNamespaces(null, null, null, null) routeList
.then((response) => response.body) .filter(
.catch((error) => { (route) =>
logger.error("Error getting ingresses: %d %s %s", error.statusCode, error.body, error.response); route.metadata.annotations &&
logger.debug(error); route.metadata.annotations[`${ANNOTATION_BASE}/enabled`] === "true" &&
return null; (!route.metadata.annotations[`${ANNOTATION_BASE}/instance`] ||
}); route.metadata.annotations[`${ANNOTATION_BASE}/instance`] === instanceName ||
`${ANNOTATION_BASE}/instance.${instanceName}` in route.metadata.annotations),
const traefikContainoExists = await checkCRD(kc, "ingressroutes.traefik.containo.us"); )
const traefikExists = await checkCRD(kc, "ingressroutes.traefik.io"); .map(async (route) => {
let constructedService = {
const traefikIngressListContaino = await crd app: route.metadata.annotations[`${ANNOTATION_BASE}/app`] || route.metadata.name,
.listClusterCustomObject("traefik.containo.us", "v1alpha1", "ingressroutes") namespace: route.metadata.namespace,
.then((response) => response.body) href: route.metadata.annotations[`${ANNOTATION_BASE}/href`] || (await getUrlSchema(route)),
.catch(async (error) => { name: route.metadata.annotations[`${ANNOTATION_BASE}/name`] || route.metadata.name,
if (traefikContainoExists) { group: route.metadata.annotations[`${ANNOTATION_BASE}/group`] || "Kubernetes",
logger.error( weight: route.metadata.annotations[`${ANNOTATION_BASE}/weight`] || "0",
"Error getting traefik ingresses from traefik.containo.us: %d %s %s", icon: route.metadata.annotations[`${ANNOTATION_BASE}/icon`] || "",
error.statusCode, description: route.metadata.annotations[`${ANNOTATION_BASE}/description`] || "",
error.body, external: false,
error.response, type: "service",
); };
logger.debug(error); if (route.metadata.annotations[`${ANNOTATION_BASE}/external`]) {
} constructedService.external =
String(route.metadata.annotations[`${ANNOTATION_BASE}/external`]).toLowerCase() === "true";
return [];
});
const traefikIngressListIo = await crd
.listClusterCustomObject("traefik.io", "v1alpha1", "ingressroutes")
.then((response) => response.body)
.catch(async (error) => {
if (traefikExists) {
logger.error(
"Error getting traefik ingresses from traefik.io: %d %s %s",
error.statusCode,
error.body,
error.response,
);
logger.debug(error);
}
return [];
});
const traefikIngressList = [...(traefikIngressListContaino?.items ?? []), ...(traefikIngressListIo?.items ?? [])];
if (traefikIngressList.length > 0) {
const traefikServices = traefikIngressList.filter(
(ingress) => ingress.metadata.annotations && ingress.metadata.annotations[`${ANNOTATION_BASE}/href`],
);
ingressList.items.push(...traefikServices);
}
if (!ingressList) {
return [];
}
const services = ingressList.items
.filter(
(ingress) =>
ingress.metadata.annotations &&
ingress.metadata.annotations[`${ANNOTATION_BASE}/enabled`] === "true" &&
(!ingress.metadata.annotations[`${ANNOTATION_BASE}/instance`] ||
ingress.metadata.annotations[`${ANNOTATION_BASE}/instance`] === instanceName ||
`${ANNOTATION_BASE}/instance.${instanceName}` in ingress.metadata.annotations),
)
.map((ingress) => {
let constructedService = {
app: ingress.metadata.annotations[`${ANNOTATION_BASE}/app`] || ingress.metadata.name,
namespace: ingress.metadata.namespace,
href: ingress.metadata.annotations[`${ANNOTATION_BASE}/href`] || getUrlFromIngress(ingress),
name: ingress.metadata.annotations[`${ANNOTATION_BASE}/name`] || ingress.metadata.name,
group: ingress.metadata.annotations[`${ANNOTATION_BASE}/group`] || "Kubernetes",
weight: ingress.metadata.annotations[`${ANNOTATION_BASE}/weight`] || "0",
icon: ingress.metadata.annotations[`${ANNOTATION_BASE}/icon`] || "",
description: ingress.metadata.annotations[`${ANNOTATION_BASE}/description`] || "",
external: false,
type: "service",
};
if (ingress.metadata.annotations[`${ANNOTATION_BASE}/external`]) {
constructedService.external =
String(ingress.metadata.annotations[`${ANNOTATION_BASE}/external`]).toLowerCase() === "true";
}
if (ingress.metadata.annotations[`${ANNOTATION_BASE}/pod-selector`] !== undefined) {
constructedService.podSelector = ingress.metadata.annotations[`${ANNOTATION_BASE}/pod-selector`];
}
if (ingress.metadata.annotations[`${ANNOTATION_BASE}/ping`]) {
constructedService.ping = ingress.metadata.annotations[`${ANNOTATION_BASE}/ping`];
}
if (ingress.metadata.annotations[`${ANNOTATION_BASE}/siteMonitor`]) {
constructedService.siteMonitor = ingress.metadata.annotations[`${ANNOTATION_BASE}/siteMonitor`];
}
if (ingress.metadata.annotations[`${ANNOTATION_BASE}/statusStyle`]) {
constructedService.statusStyle = ingress.metadata.annotations[`${ANNOTATION_BASE}/statusStyle`];
}
Object.keys(ingress.metadata.annotations).forEach((annotation) => {
if (annotation.startsWith(ANNOTATION_WIDGET_BASE)) {
shvl.set(
constructedService,
annotation.replace(`${ANNOTATION_BASE}/`, ""),
ingress.metadata.annotations[annotation],
);
} }
}); if (route.metadata.annotations[`${ANNOTATION_BASE}/pod-selector`] !== undefined) {
constructedService.podSelector = route.metadata.annotations[`${ANNOTATION_BASE}/pod-selector`];
}
if (route.metadata.annotations[`${ANNOTATION_BASE}/ping`]) {
constructedService.ping = route.metadata.annotations[`${ANNOTATION_BASE}/ping`];
}
if (route.metadata.annotations[`${ANNOTATION_BASE}/siteMonitor`]) {
constructedService.siteMonitor = route.metadata.annotations[`${ANNOTATION_BASE}/siteMonitor`];
}
if (route.metadata.annotations[`${ANNOTATION_BASE}/statusStyle`]) {
constructedService.statusStyle = route.metadata.annotations[`${ANNOTATION_BASE}/statusStyle`];
}
Object.keys(route.metadata.annotations).forEach((annotation) => {
if (annotation.startsWith(ANNOTATION_WIDGET_BASE)) {
shvl.set(
constructedService,
annotation.replace(`${ANNOTATION_BASE}/`, ""),
route.metadata.annotations[annotation],
);
}
});
try { try {
constructedService = JSON.parse(substituteEnvironmentVars(JSON.stringify(constructedService))); constructedService = JSON.parse(substituteEnvironmentVars(JSON.stringify(constructedService)));
} catch (e) { } catch (e) {
logger.error("Error attempting k8s environment variable substitution."); logger.error("Error attempting k8s environment variable substitution.");
logger.debug(e); logger.debug(e);
} }
return constructedService;
return constructedService; }),
}); );
const mappedServiceGroups = []; const mappedServiceGroups = [];
@ -354,8 +284,12 @@ export function cleanServiceGroups(groups) {
if (typeof cleanedService.weight !== "number") { if (typeof cleanedService.weight !== "number") {
cleanedService.weight = 0; cleanedService.weight = 0;
} }
if (!cleanedService.widgets) cleanedService.widgets = [];
if (cleanedService.widget) { if (cleanedService.widget) {
cleanedService.widgets.push(cleanedService.widget);
delete cleanedService.widget;
}
cleanedService.widgets = cleanedService.widgets.map((widgetData, index) => {
// whitelisted set of keys to pass to the frontend // whitelisted set of keys to pass to the frontend
// alphabetical, grouped by widget(s) // alphabetical, grouped by widget(s)
const { const {
@ -390,6 +324,9 @@ export function cleanServiceGroups(groups) {
mappings, mappings,
display, display,
// deluge, qbittorrent
enableLeechProgress,
// diskstation // diskstation
volume, volume,
@ -409,7 +346,7 @@ export function cleanServiceGroups(groups) {
// frigate // frigate
enableRecentEvents, enableRecentEvents,
// glances, immich, mealie, pihole, pfsense // beszel, glances, immich, mealie, pihole, pfsense
version, version,
// glances // glances
@ -492,7 +429,10 @@ export function cleanServiceGroups(groups) {
// technitium // technitium
range, range,
} = cleanedService.widget;
// spoolman
spoolIds,
} = widgetData;
let fieldsList = fields; let fieldsList = fields;
if (typeof fields === "string") { if (typeof fields === "string") {
@ -504,166 +444,191 @@ export function cleanServiceGroups(groups) {
} }
} }
cleanedService.widget = { const widget = {
type, type,
fields: fieldsList || null, fields: fieldsList || null,
hide_errors: hideErrors || false, hide_errors: hideErrors || false,
service_name: service.name, service_name: service.name,
service_group: serviceGroup.name, service_group: serviceGroup.name,
index,
}; };
if (type === "azuredevops") { if (type === "azuredevops") {
if (userEmail) cleanedService.widget.userEmail = userEmail; if (userEmail) widget.userEmail = userEmail;
if (repositoryId) cleanedService.widget.repositoryId = repositoryId; if (repositoryId) widget.repositoryId = repositoryId;
} }
if (type === "beszel") { if (type === "beszel") {
if (systemId) cleanedService.widget.systemId = systemId; if (systemId) widget.systemId = systemId;
} }
if (type === "coinmarketcap") { if (type === "coinmarketcap") {
if (currency) cleanedService.widget.currency = currency; if (currency) widget.currency = currency;
if (symbols) cleanedService.widget.symbols = symbols; if (symbols) widget.symbols = symbols;
if (slugs) cleanedService.widget.slugs = slugs; if (slugs) widget.slugs = slugs;
if (defaultinterval) cleanedService.widget.defaultinterval = defaultinterval; if (defaultinterval) widget.defaultinterval = defaultinterval;
} }
if (type === "docker") { if (type === "docker") {
if (server) cleanedService.widget.server = server; if (server) widget.server = server;
if (container) cleanedService.widget.container = container; if (container) widget.container = container;
} }
if (type === "unifi") { if (type === "unifi") {
if (site) cleanedService.widget.site = site; if (site) widget.site = site;
} }
if (type === "proxmox") { if (type === "proxmox") {
if (node) cleanedService.widget.node = node; if (node) widget.node = node;
} }
if (type === "kubernetes") { if (type === "kubernetes") {
if (namespace) cleanedService.widget.namespace = namespace; if (namespace) widget.namespace = namespace;
if (app) cleanedService.widget.app = app; if (app) widget.app = app;
if (podSelector) cleanedService.widget.podSelector = podSelector; if (podSelector) widget.podSelector = podSelector;
} }
if (type === "iframe") { if (type === "iframe") {
if (src) cleanedService.widget.src = src; if (src) widget.src = src;
if (classes) cleanedService.widget.classes = classes; if (classes) widget.classes = classes;
if (referrerPolicy) cleanedService.widget.referrerPolicy = referrerPolicy; if (referrerPolicy) widget.referrerPolicy = referrerPolicy;
if (allowPolicy) cleanedService.widget.allowPolicy = allowPolicy; if (allowPolicy) widget.allowPolicy = allowPolicy;
if (allowFullscreen) cleanedService.widget.allowFullscreen = allowFullscreen; if (allowFullscreen) widget.allowFullscreen = allowFullscreen;
if (loadingStrategy) cleanedService.widget.loadingStrategy = loadingStrategy; if (loadingStrategy) widget.loadingStrategy = loadingStrategy;
if (allowScrolling) cleanedService.widget.allowScrolling = allowScrolling; if (allowScrolling) widget.allowScrolling = allowScrolling;
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval; if (refreshInterval) widget.refreshInterval = refreshInterval;
}
if (["deluge", "qbittorrent"].includes(type)) {
if (enableLeechProgress !== undefined) widget.enableLeechProgress = JSON.parse(enableLeechProgress);
} }
if (["opnsense", "pfsense"].includes(type)) { if (["opnsense", "pfsense"].includes(type)) {
if (wan) cleanedService.widget.wan = wan; if (wan) widget.wan = wan;
} }
if (["emby", "jellyfin"].includes(type)) { if (["emby", "jellyfin"].includes(type)) {
if (enableBlocks !== undefined) cleanedService.widget.enableBlocks = JSON.parse(enableBlocks); if (enableBlocks !== undefined) widget.enableBlocks = JSON.parse(enableBlocks);
if (enableNowPlaying !== undefined) cleanedService.widget.enableNowPlaying = JSON.parse(enableNowPlaying); if (enableNowPlaying !== undefined) widget.enableNowPlaying = JSON.parse(enableNowPlaying);
} }
if (["emby", "jellyfin", "tautulli"].includes(type)) { if (["emby", "jellyfin", "tautulli"].includes(type)) {
if (expandOneStreamToTwoRows !== undefined) if (expandOneStreamToTwoRows !== undefined)
cleanedService.widget.expandOneStreamToTwoRows = !!JSON.parse(expandOneStreamToTwoRows); widget.expandOneStreamToTwoRows = !!JSON.parse(expandOneStreamToTwoRows);
if (showEpisodeNumber !== undefined) if (showEpisodeNumber !== undefined) widget.showEpisodeNumber = !!JSON.parse(showEpisodeNumber);
cleanedService.widget.showEpisodeNumber = !!JSON.parse(showEpisodeNumber); if (enableUser !== undefined) widget.enableUser = !!JSON.parse(enableUser);
if (enableUser !== undefined) cleanedService.widget.enableUser = !!JSON.parse(enableUser);
} }
if (["sonarr", "radarr"].includes(type)) { if (["sonarr", "radarr"].includes(type)) {
if (enableQueue !== undefined) cleanedService.widget.enableQueue = JSON.parse(enableQueue); if (enableQueue !== undefined) widget.enableQueue = JSON.parse(enableQueue);
} }
if (type === "truenas") { if (type === "truenas") {
if (enablePools !== undefined) cleanedService.widget.enablePools = JSON.parse(enablePools); if (enablePools !== undefined) widget.enablePools = JSON.parse(enablePools);
if (nasType !== undefined) cleanedService.widget.nasType = nasType; if (nasType !== undefined) widget.nasType = nasType;
} }
if (["diskstation", "qnap"].includes(type)) { if (["diskstation", "qnap"].includes(type)) {
if (volume) cleanedService.widget.volume = volume; if (volume) widget.volume = volume;
} }
if (type === "kopia") { if (type === "kopia") {
if (snapshotHost) cleanedService.widget.snapshotHost = snapshotHost; if (snapshotHost) widget.snapshotHost = snapshotHost;
if (snapshotPath) cleanedService.widget.snapshotPath = snapshotPath; if (snapshotPath) widget.snapshotPath = snapshotPath;
} }
if (["glances", "immich", "mealie", "pfsense", "pihole"].includes(type)) { if (["beszel", "glances", "immich", "mealie", "pfsense", "pihole"].includes(type)) {
if (version) cleanedService.widget.version = parseInt(version, 10); if (version) widget.version = parseInt(version, 10);
} }
if (type === "glances") { if (type === "glances") {
if (metric) cleanedService.widget.metric = metric; if (metric) widget.metric = metric;
if (chart !== undefined) { if (chart !== undefined) {
cleanedService.widget.chart = chart; widget.chart = chart;
} else { } else {
cleanedService.widget.chart = true; widget.chart = true;
} }
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval; if (refreshInterval) widget.refreshInterval = refreshInterval;
if (pointsLimit) cleanedService.widget.pointsLimit = pointsLimit; if (pointsLimit) widget.pointsLimit = pointsLimit;
if (diskUnits) cleanedService.widget.diskUnits = diskUnits; if (diskUnits) widget.diskUnits = diskUnits;
} }
if (type === "mjpeg") { if (type === "mjpeg") {
if (stream) cleanedService.widget.stream = stream; if (stream) widget.stream = stream;
if (fit) cleanedService.widget.fit = fit; if (fit) widget.fit = fit;
} }
if (type === "openmediavault") { if (type === "openmediavault") {
if (method) cleanedService.widget.method = method; if (method) widget.method = method;
} }
if (type === "openwrt") { if (type === "openwrt") {
if (interfaceName) cleanedService.widget.interfaceName = interfaceName; if (interfaceName) widget.interfaceName = interfaceName;
} }
if (type === "customapi") { if (type === "customapi") {
if (mappings) cleanedService.widget.mappings = mappings; if (mappings) widget.mappings = mappings;
if (display) cleanedService.widget.display = display; if (display) widget.display = display;
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval; if (refreshInterval) widget.refreshInterval = refreshInterval;
} }
if (type === "calendar") { if (type === "calendar") {
if (integrations) cleanedService.widget.integrations = integrations; if (integrations) widget.integrations = integrations;
if (firstDayInWeek) cleanedService.widget.firstDayInWeek = firstDayInWeek; if (firstDayInWeek) widget.firstDayInWeek = firstDayInWeek;
if (view) cleanedService.widget.view = view; if (view) widget.view = view;
if (maxEvents) cleanedService.widget.maxEvents = maxEvents; if (maxEvents) widget.maxEvents = maxEvents;
if (previousDays) cleanedService.widget.previousDays = previousDays; if (previousDays) widget.previousDays = previousDays;
if (showTime) cleanedService.widget.showTime = showTime; if (showTime) widget.showTime = showTime;
if (timezone) cleanedService.widget.timezone = timezone; if (timezone) widget.timezone = timezone;
} }
if (type === "hdhomerun") { if (type === "hdhomerun") {
if (tuner !== undefined) cleanedService.widget.tuner = tuner; if (tuner !== undefined) widget.tuner = tuner;
} }
if (type === "healthchecks") { if (type === "healthchecks") {
if (uuid !== undefined) cleanedService.widget.uuid = uuid; if (uuid !== undefined) widget.uuid = uuid;
} }
if (type === "speedtest") { if (type === "speedtest") {
if (bitratePrecision !== undefined) { if (bitratePrecision !== undefined) {
cleanedService.widget.bitratePrecision = parseInt(bitratePrecision, 10); widget.bitratePrecision = parseInt(bitratePrecision, 10);
} }
} }
if (type === "stocks") { if (type === "stocks") {
if (watchlist) cleanedService.widget.watchlist = watchlist; if (watchlist) widget.watchlist = watchlist;
if (showUSMarketStatus) cleanedService.widget.showUSMarketStatus = showUSMarketStatus; if (showUSMarketStatus) widget.showUSMarketStatus = showUSMarketStatus;
} }
if (type === "wgeasy") { if (type === "wgeasy") {
if (threshold !== undefined) cleanedService.widget.threshold = parseInt(threshold, 10); if (threshold !== undefined) widget.threshold = parseInt(threshold, 10);
} }
if (type === "frigate") { if (type === "frigate") {
if (enableRecentEvents !== undefined) cleanedService.widget.enableRecentEvents = enableRecentEvents; if (enableRecentEvents !== undefined) widget.enableRecentEvents = enableRecentEvents;
} }
if (type === "technitium") { if (type === "technitium") {
if (range !== undefined) cleanedService.widget.range = range; if (range !== undefined) widget.range = range;
} }
if (type === "lubelogger") { if (type === "lubelogger") {
if (vehicleID !== undefined) cleanedService.widget.vehicleID = parseInt(vehicleID, 10); if (vehicleID !== undefined) widget.vehicleID = parseInt(vehicleID, 10);
} }
if (type === "vikunja") { if (type === "vikunja") {
if (enableTaskList !== undefined) cleanedService.widget.enableTaskList = !!enableTaskList; if (enableTaskList !== undefined) widget.enableTaskList = !!enableTaskList;
} }
if (type === "prometheusmetric") { if (type === "prometheusmetric") {
if (metrics) cleanedService.widget.metrics = metrics; if (metrics) widget.metrics = metrics;
if (refreshInterval) cleanedService.widget.refreshInterval = refreshInterval; if (refreshInterval) widget.refreshInterval = refreshInterval;
} }
} if (type === "spoolman") {
if (spoolIds !== undefined) widget.spoolIds = spoolIds;
}
return widget;
});
return cleanedService; return cleanedService;
}), }),
type: serviceGroup.type || "group",
groups: serviceGroup.groups ? cleanServiceGroups(serviceGroup.groups) : [],
})); }));
} }
export function findGroupByName(groups, name) {
// Deep search for a group by name. Using for loop allows for early return
for (let i = 0; i < groups.length; i += 1) {
const group = groups[i];
if (group.name === name) {
return group;
} else if (group.groups) {
const foundGroup = findGroupByName(group.groups, name);
if (foundGroup) {
foundGroup.parent = group;
return foundGroup;
}
}
}
return null;
}
export async function getServiceItem(group, service) { export async function getServiceItem(group, service) {
const configuredServices = await servicesFromConfig(); const configuredServices = await servicesFromConfig();
const serviceGroup = configuredServices.find((g) => g.name === group); const serviceGroup = findGroupByName(configuredServices, group);
if (serviceGroup) { if (serviceGroup) {
const serviceEntry = serviceGroup.services.find((s) => s.name === service); const serviceEntry = serviceGroup.services.find((s) => s.name === service);
if (serviceEntry) return serviceEntry; if (serviceEntry) return serviceEntry;
@ -671,14 +636,14 @@ export async function getServiceItem(group, service) {
const discoveredServices = await servicesFromDocker(); const discoveredServices = await servicesFromDocker();
const dockerServiceGroup = discoveredServices.find((g) => g.name === group); const dockerServiceGroup = findGroupByName(discoveredServices, group);
if (dockerServiceGroup) { if (dockerServiceGroup) {
const dockerServiceEntry = dockerServiceGroup.services.find((s) => s.name === service); const dockerServiceEntry = dockerServiceGroup.services.find((s) => s.name === service);
if (dockerServiceEntry) return dockerServiceEntry; if (dockerServiceEntry) return dockerServiceEntry;
} }
const kubernetesServices = await servicesFromKubernetes(); const kubernetesServices = await servicesFromKubernetes();
const kubernetesServiceGroup = kubernetesServices.find((g) => g.name === group); const kubernetesServiceGroup = findGroupByName(kubernetesServices, group);
if (kubernetesServiceGroup) { if (kubernetesServiceGroup) {
const kubernetesServiceEntry = kubernetesServiceGroup.services.find((s) => s.name === service); const kubernetesServiceEntry = kubernetesServiceGroup.services.find((s) => s.name === service);
if (kubernetesServiceEntry) return kubernetesServiceEntry; if (kubernetesServiceEntry) return kubernetesServiceEntry;
@ -687,12 +652,11 @@ export async function getServiceItem(group, service) {
return false; return false;
} }
export default async function getServiceWidget(group, service) { export default async function getServiceWidget(group, service, index) {
const serviceItem = await getServiceItem(group, service); const serviceItem = await getServiceItem(group, service);
if (serviceItem) { if (serviceItem) {
const { widget } = serviceItem; const { widget, widgets } = serviceItem;
return widget; return index > -1 && widgets ? widgets[index] : widget;
} }
return false; return false;
} }

View File

@ -0,0 +1,211 @@
import { CustomObjectsApi, NetworkingV1Api, CoreV1Api, ApiextensionsV1Api } from "@kubernetes/client-node";
import getKubeArguments from "utils/config/kubernetes";
import createLogger from "utils/logger";
const logger = createLogger("service-helpers");
const kubeArguments = getKubeArguments();
const kc = kubeArguments.config;
const apiGroup = "gateway.networking.k8s.io";
const version = "v1";
let crd;
let core;
let networking;
let routingType;
let traefik;
export async function checkCRD(name) {
const apiExtensions = kc.makeApiClient(ApiextensionsV1Api);
const exist = await apiExtensions
.readCustomResourceDefinitionStatus(name)
.then(() => true)
.catch(async (error) => {
if (error.statusCode === 403) {
logger.error(
"Error checking if CRD %s exists. Make sure to add the following permission to your RBAC: %d %s %s",
name,
error.statusCode,
error.body.message,
);
}
return false;
});
return exist;
}
const getSchemaFromGateway = async (gatewayRef) => {
const schema = await crd
.getNamespacedCustomObject(apiGroup, version, gatewayRef.namespace, "gateways", gatewayRef.name)
.then((response) => {
const listner = response.body.spec.listeners.filter((listener) => listener.name === gatewayRef.sectionName)[0];
return listner.protocol.toLowerCase();
})
.catch((error) => {
logger.error("Error getting gateways: %d %s %s", error.statusCode, error.body, error.response);
logger.debug(error);
return "";
});
return schema;
};
async function getUrlFromHttpRoute(ingress) {
const urlHost = ingress.spec.hostnames[0];
const urlPath = ingress.spec.rules[0].matches[0].path.value;
const urlSchema = (await getSchemaFromGateway(ingress.spec.parentRefs[0])) ? "https" : "http";
return `${urlSchema}://${urlHost}${urlPath}`;
}
function getUrlFromIngress(ingress) {
const urlHost = ingress.spec.rules[0].host;
const urlPath = ingress.spec.rules[0].http.paths[0].path;
const urlSchema = ingress.spec.tls ? "https" : "http";
return `${urlSchema}://${urlHost}${urlPath}`;
}
async function getHttpRouteList() {
// httproutes
const getHttpRoute = async (namespace) =>
crd
.listNamespacedCustomObject(apiGroup, version, namespace, "httproutes")
.then((response) => {
const [httpRoute] = response.body.items;
return httpRoute;
})
.catch((error) => {
logger.error("Error getting httproutes: %d %s %s", error.statusCode, error.body, error.response);
logger.debug(error);
return null;
});
// namespaces
const namespaces = await core
.listNamespace()
.then((response) => response.body.items.map((ns) => ns.metadata.name))
.catch((error) => {
logger.error("Error getting namespaces: %d %s %s", error.statusCode, error.body, error.response);
logger.debug(error);
return null;
});
let httpRouteList = [];
if (namespaces) {
const httpRouteListUnfiltered = await Promise.all(
namespaces.map(async (namespace) => {
const httpRoute = await getHttpRoute(namespace);
return httpRoute;
}),
);
httpRouteList = httpRouteListUnfiltered.filter((httpRoute) => httpRoute !== undefined);
}
return httpRouteList;
}
async function getIngressList(ANNOTATION_BASE) {
const ingressList = await networking
.listIngressForAllNamespaces(null, null, null, null)
.then((response) => response.body)
.catch((error) => {
logger.error("Error getting ingresses: %d %s %s", error.statusCode, error.body, error.response);
logger.debug(error);
return null;
});
if (traefik) {
const traefikContainoExists = await checkCRD("ingressroutes.traefik.containo.us");
const traefikExists = await checkCRD("ingressroutes.traefik.io");
const traefikIngressListContaino = await crd
.listClusterCustomObject("traefik.containo.us", "v1alpha1", "ingressroutes")
.then((response) => response.body)
.catch(async (error) => {
if (traefikContainoExists) {
logger.error(
"Error getting traefik ingresses from traefik.containo.us: %d %s %s",
error.statusCode,
error.body,
error.response,
);
logger.debug(error);
}
return [];
});
const traefikIngressListIo = await crd
.listClusterCustomObject("traefik.io", "v1alpha1", "ingressroutes")
.then((response) => response.body)
.catch(async (error) => {
if (traefikExists) {
logger.error(
"Error getting traefik ingresses from traefik.io: %d %s %s",
error.statusCode,
error.body,
error.response,
);
logger.debug(error);
}
return [];
});
const traefikIngressList = [...(traefikIngressListContaino?.items ?? []), ...(traefikIngressListIo?.items ?? [])];
if (traefikIngressList.length > 0) {
const traefikServices = traefikIngressList.filter(
(ingress) => ingress.metadata.annotations && ingress.metadata.annotations[`${ANNOTATION_BASE}/href`],
);
ingressList.items.push(...traefikServices);
}
}
return ingressList.items;
}
export async function getRouteList(ANNOTATION_BASE) {
let routeList = [];
if (!kc) {
return [];
}
crd = kc.makeApiClient(CustomObjectsApi);
core = kc.makeApiClient(CoreV1Api);
networking = kc.makeApiClient(NetworkingV1Api);
routingType = kubeArguments.route;
traefik = kubeArguments.traefik;
switch (routingType) {
case "ingress":
routeList = await getIngressList(ANNOTATION_BASE);
break;
case "gateway":
routeList = await getHttpRouteList();
break;
default:
routeList = await getIngressList(ANNOTATION_BASE);
}
return routeList;
}
export async function getUrlSchema(route) {
let urlSchema;
switch (routingType) {
case "ingress":
urlSchema = getUrlFromIngress(route);
break;
case "gateway":
urlSchema = await getUrlFromHttpRoute(route);
break;
default:
urlSchema = getUrlFromIngress(route);
}
return urlSchema;
}