Enhancement: Komodo widget (#5407)

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
Andrew Ensley 2025-06-11 12:05:53 -04:00 committed by GitHub
parent 6e16adc460
commit 8ce9e57ed8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 215 additions and 0 deletions

View File

@ -0,0 +1,22 @@
---
title: Komodo
description: Komodo Widget Configuration
---
This widget shows either details about all containers or stacks (if `showStacks` is true) managed by [Komodo](https://komo.do/) or the number of running servers, containers and stacks when `showSummary` is enabled.
The api key and secret can be found in the Komodo settings.
Allowed fields (max 4): `["total", "running", "stopped", "unhealthy", "unknown"]`.
Allowed fields with `showStacks` (max 4): `["total", "running", "down", "unhealthy", "unknown"]`.
Allowed fields with `showSummary`: `["servers", "stacks", "containers"]`.
```yaml
widget:
type: komodo
url: http://komodo.hostname.or.ip:port
key: K-xxxxxx...
secret: S-xxxxxx...
showSummary: true # optional, default: false
showStacks: true # optional, default: false
```

View File

@ -1060,5 +1060,16 @@
"checkmk": { "checkmk": {
"serviceErrors": "Service issues", "serviceErrors": "Service issues",
"hostErrors": "Host issues" "hostErrors": "Host issues"
},
"komodo": {
"total": "Total",
"running": "Running",
"stopped": "Stopped",
"down": "Down",
"unhealthy": "Unhealthy",
"unknown": "Unknown",
"servers": "Servers",
"stacks": "Stacks",
"containers": "Containers"
} }
} }

View File

@ -338,6 +338,10 @@ export function cleanServiceGroups(groups) {
// jellystat // jellystat
days, days,
// komodo
showSummary,
showStacks,
// kopia // kopia
snapshotHost, snapshotHost,
snapshotPath, snapshotPath,
@ -450,6 +454,10 @@ export function cleanServiceGroups(groups) {
if (type === "proxmoxbackupserver") { if (type === "proxmoxbackupserver") {
if (datastore) widget.datastore = datastore; if (datastore) widget.datastore = datastore;
} }
if (type === "komodo") {
if (showSummary !== undefined) widget.showSummary = !!JSON.parse(showSummary);
if (showStacks !== undefined) widget.showStacks = !!JSON.parse(showStacks);
}
if (type === "kubernetes") { if (type === "kubernetes") {
if (namespace) widget.namespace = namespace; if (namespace) widget.namespace = namespace;
if (app) widget.app = app; if (app) widget.app = app;

View File

@ -63,6 +63,7 @@ const components = {
jellystat: dynamic(() => import("./jellystat/component")), jellystat: dynamic(() => import("./jellystat/component")),
kavita: dynamic(() => import("./kavita/component")), kavita: dynamic(() => import("./kavita/component")),
komga: dynamic(() => import("./komga/component")), komga: dynamic(() => import("./komga/component")),
komodo: dynamic(() => import("./komodo/component")),
kopia: dynamic(() => import("./kopia/component")), kopia: dynamic(() => import("./kopia/component")),
lidarr: dynamic(() => import("./lidarr/component")), lidarr: dynamic(() => import("./lidarr/component")),
linkwarden: dynamic(() => import("./linkwarden/component")), linkwarden: dynamic(() => import("./linkwarden/component")),

View File

@ -0,0 +1,84 @@
import Block from "components/services/widget/block";
import Container from "components/services/widget/container";
import { useTranslation } from "next-i18next";
import useWidgetAPI from "utils/proxy/use-widget-api";
const MAX_ALLOWED_FIELDS = 4;
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const containersEndpoint = !(!widget.showSummary && widget.showStacks) ? "containers" : "";
const { data: containersData, error: containersError } = useWidgetAPI(widget, containersEndpoint);
const stacksEndpoint = widget.showSummary || widget.showStacks ? "stacks" : "";
const { data: stacksData, error: stacksError } = useWidgetAPI(widget, stacksEndpoint);
const serversEndpoint = widget.showSummary ? "servers" : "";
const { data: serversData, error: serversError } = useWidgetAPI(widget, serversEndpoint);
if (containersError || stacksError || serversError) {
return <Container service={service} error={containersError ?? stacksError ?? serversError} />;
}
if (!widget.fields || widget.fields.length === 0) {
widget.fields = widget.showSummary
? ["servers", "stacks", "containers"]
: widget.showStacks
? ["total", "running", "down", "unhealthy"]
: ["total", "running", "stopped", "unhealthy"];
} else if (widget.fields?.length > MAX_ALLOWED_FIELDS) {
widget.fields = widget.fields.slice(0, MAX_ALLOWED_FIELDS);
}
if (
(!widget.showStacks && !containersData) ||
(widget.showSummary && (!stacksData || !serversData)) ||
(widget.showStacks && !stacksData)
) {
return widget.showSummary ? (
<Container service={service}>
<Block label="komodo.servers" />
<Block label="komodo.stacks" />
<Block label="komodo.containers" />
</Container>
) : widget.showStacks ? (
<Container service={service}>
<Block label="komodo.total" />
<Block label="komodo.running" />
<Block label="komodo.down" />
<Block label="komodo.unhealthy" />
</Container>
) : (
<Container service={service}>
<Block label="komodo.total" />
<Block label="komodo.running" />
<Block label="komodo.stopped" />
<Block label="komodo.unhealthy" />
</Container>
);
}
return widget.showSummary ? (
<Container service={service}>
<Block label="komodo.servers" value={`${serversData.healthy} / ${serversData.total}`} />
<Block label="komodo.stacks" value={`${stacksData.running} / ${stacksData.total}`} />
<Block label="komodo.containers" value={`${containersData.running} / ${containersData.total}`} />
</Container>
) : widget.showStacks ? (
<Container service={service}>
<Block label="komodo.total" value={t("common.number", { value: stacksData.total })} />
<Block label="komodo.running" value={t("common.number", { value: stacksData.running })} />
<Block label="komodo.down" value={t("common.number", { value: stacksData.stopped + stacksData.down })} />
<Block label="komodo.unhealthy" value={t("common.number", { value: stacksData.unhealthy })} />
<Block label="komodo.unknown" value={t("common.number", { value: stacksData.unknown })} />
</Container>
) : (
<Container service={service}>
<Block label="komodo.total" value={t("common.number", { value: containersData.total })} />
<Block label="komodo.running" value={t("common.number", { value: containersData.running })} />
<Block label="komodo.stopped" value={t("common.number", { value: containersData.stopped })} />
<Block label="komodo.unhealthy" value={t("common.number", { value: containersData.unhealthy })} />
<Block label="komodo.unknown" value={t("common.number", { value: containersData.unknown })} />
</Container>
);
}

View File

@ -0,0 +1,55 @@
import getServiceWidget from "utils/config/service-helpers";
import createLogger from "utils/logger";
import { formatApiCall, sanitizeErrorURL } from "utils/proxy/api-helpers";
import { httpProxy } from "utils/proxy/http";
import validateWidgetData from "utils/proxy/validate-widget-data";
import widgets from "widgets/widgets";
const logger = createLogger("komodoProxyHandler");
export default async function komodoProxyHandler(req, res) {
const { group, service, endpoint, index } = req.query;
if (group && service) {
const widget = await getServiceWidget(group, service, index);
if (!widgets?.[widget.type]?.api) {
return res.status(403).json({ error: "Service does not support API calls" });
}
if (widget) {
// api uses unified read endpoint
const url = new URL(formatApiCall(widgets[widget.type].api, { endpoint: "read", ...widget })).toString();
const headers = {
"Content-Type": "application/json",
"X-API-Key": `${widget.key}`,
"X-API-Secret": `${widget.secret}`,
};
const [status, contentType, data] = await httpProxy(url, {
method: "POST",
body: JSON.stringify(widgets[widget.type].mappings?.[endpoint]?.body || {}),
headers,
});
let resultData = data;
if (status >= 400) {
logger.error("HTTP Error %d calling %s", status, sanitizeErrorURL(url));
}
if (status === 200) {
if (!validateWidgetData(widget, endpoint, resultData)) {
return res
.status(500)
.json({ error: { message: "Invalid data", url: sanitizeErrorURL(url), data: resultData } });
}
}
if (contentType) res.setHeader("Content-Type", contentType);
return res.status(status).send(resultData);
}
}
logger.debug("Invalid or missing proxy service type '%s' in group '%s'", service, group);
return res.status(400).json({ error: "Invalid proxy service type" });
}

View File

@ -0,0 +1,32 @@
import komodoProxyHandler from "./proxy";
const widget = {
api: "{url}/{endpoint}",
proxyHandler: komodoProxyHandler,
mappings: {
containers: {
endpoint: "containers", // api actually uses unified read endpoint
body: {
type: "GetDockerContainersSummary",
params: {},
},
},
stacks: {
endpoint: "stacks", // api actually uses unified read endpoint
body: {
type: "GetStacksSummary",
params: {},
},
},
servers: {
endpoint: "servers", // api actually uses unified read endpoint
body: {
type: "GetServersSummary",
params: {},
},
},
},
};
export default widget;

View File

@ -54,6 +54,7 @@ import jellystat from "./jellystat/widget";
import karakeep from "./karakeep/widget"; import karakeep from "./karakeep/widget";
import kavita from "./kavita/widget"; import kavita from "./kavita/widget";
import komga from "./komga/widget"; import komga from "./komga/widget";
import komodo from "./komodo/widget";
import kopia from "./kopia/widget"; import kopia from "./kopia/widget";
import lidarr from "./lidarr/widget"; import lidarr from "./lidarr/widget";
import linkwarden from "./linkwarden/widget"; import linkwarden from "./linkwarden/widget";
@ -197,6 +198,7 @@ const widgets = {
jellystat, jellystat,
kavita, kavita,
komga, komga,
komodo,
kopia, kopia,
lidarr, lidarr,
linkwarden, linkwarden,