new status format, new podSelector field, more accurate pod stats

* renamed pod label prefix from "homepage" to "gethomepage.dev"
  which is more inline with typical kubernetes practices
This commit is contained in:
James Wynn 2022-12-08 16:03:29 -06:00
parent 174cb651b4
commit 09eb172079
8 changed files with 65 additions and 40 deletions

View File

@ -95,7 +95,7 @@ export default function Item({ service }) {
<button <button
type="button" type="button"
onClick={() => (statsOpen ? closeStats() : setStatsOpen(true))} onClick={() => (statsOpen ? closeStats() : setStatsOpen(true))}
className="flex-shrink-0 flex items-center justify-center w-12 cursor-pointer" className="flex-shrink-0 flex items-center justify-center cursor-pointer"
> >
<KubernetesStatus service={service} /> <KubernetesStatus service={service} />
<span className="sr-only">View container stats</span> <span className="sr-only">View container stats</span>
@ -121,7 +121,7 @@ export default function Item({ service }) {
"w-full overflow-hidden transition-all duration-300 ease-in-out" "w-full overflow-hidden transition-all duration-300 ease-in-out"
)} )}
> >
{statsOpen && <Kubernetes service={{ widget: { namespace: service.namespace, app: service.app } }} />} {statsOpen && <Kubernetes service={{ widget: { namespace: service.namespace, app: service.app, podSelector: service.podSelector } }} />}
</div> </div>
)} )}

View File

@ -1,19 +1,35 @@
import useSWR from "swr"; import useSWR from "swr";
import { t } from "i18next";
export default function KubernetesStatus({ service }) { export default function KubernetesStatus({ service }) {
const { data, error } = useSWR(`/api/kubernetes/status/${service.namespace}/${service.app}`); const podSelectorString = service.podSelector !== undefined ? `podSelector=${service.podSelector}` : "";
const { data, error } = useSWR(`/api/kubernetes/status/${service.namespace}/${service.app}?${podSelectorString}`);
if (error) { if (error) {
return <div className="w-3 h-3 bg-rose-300 dark:bg-rose-500 rounded-full" />; <div className="w-auto px-1.5 py-0.5 text-center bg-theme-500/10 dark:bg-theme-900/50 rounded-b-[3px] overflow-hidden" title={data.status}>
<div className="text-[8px] font-bold text-rose-500/80 uppercase">{t("docker.error")}</div>
</div>
} }
if (data && data.status === "running") { if (data && data.status === "running") {
return <div className="w-3 h-3 bg-emerald-300 dark:bg-emerald-500 rounded-full" />; return (
<div className="w-auto px-1.5 py-0.5 text-center bg-theme-500/10 dark:bg-theme-900/50 rounded-b-[3px] overflow-hidden" title={data.health ?? data.status}>
<div className="text-[8px] font-bold text-emerald-500/80 uppercase">{data.health ?? data.status}</div>
</div>
);
} }
if (data && data.status === "not found") { if (data && (data.status === "not found" || data.status === "down" || data.status === "partial")) {
return <div className="h-2.5 w-2.5 bg-orange-400/50 dark:bg-yellow-200/40 -rotate-45" />; return (
<div className="w-auto px-1.5 py-0.5 text-center bg-theme-500/10 dark:bg-theme-900/50 rounded-b-[3px] overflow-hidden" title={data.status}>
<div className="text-[8px] font-bold text-orange-400/50 dark:text-orange-400/80 uppercase">{data.status}</div>
</div>
);
} }
return <div className="w-3 h-3 bg-black/20 dark:bg-white/40 rounded-full" />; return (
<div className="w-auto px-1.5 py-0.5 text-center bg-theme-500/10 dark:bg-theme-900/50 rounded-b-[3px] overflow-hidden">
<div className="text-[8px] font-bold text-black/20 dark:text-white/40 uppercase">{t("docker.unknown")}</div>
</div>
);
} }

View File

@ -48,10 +48,10 @@ export default function Widget({ options }) {
<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">
{cluster.show && {cluster.show &&
<Node type="cluster" options={options.cluster} data={defaultData} /> <Node type="cluster" key="cluster" options={options.cluster} data={defaultData} />
} }
{nodes.show && {nodes.show &&
<Node type="node" options={options.nodes} data={defaultData} /> <Node type="node" key="nodes" options={options.nodes} data={defaultData} />
} }
</div> </div>
</div> </div>
@ -62,11 +62,11 @@ export default function Widget({ options }) {
<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">
{cluster.show && {cluster.show &&
<Node type="cluster" options={options.cluster} data={data.cluster} /> <Node key="cluster" type="cluster" options={options.cluster} data={data.cluster} />
} }
{nodes.show && data.nodes && {nodes.show && data.nodes &&
data.nodes.map((node) => data.nodes.map((node) =>
<Node key={node} type="node" options={options.nodes} data={node} />) <Node key={node.name} type="node" options={options.nodes} data={node} />)
} }
</div> </div>
</div> </div>

View File

@ -9,7 +9,6 @@ import UsageBar from "./usage-bar";
export default function Node({ type, options, data }) { export default function Node({ type, options, data }) {
const { t } = useTranslation(); const { t } = useTranslation();
console.log("Node", type, options, data);
function icon() { function icon() {
if (type === "cluster") { if (type === "cluster") {

View File

@ -8,7 +8,7 @@ const logger = createLogger("kubernetesStatsService");
export default async function handler(req, res) { export default async function handler(req, res) {
const APP_LABEL = "app.kubernetes.io/name"; const APP_LABEL = "app.kubernetes.io/name";
const { service } = req.query; const { service, podSelector } = req.query;
const [namespace, appName] = service; const [namespace, appName] = service;
if (!namespace && !appName) { if (!namespace && !appName) {
@ -17,7 +17,7 @@ export default async function handler(req, res) {
}); });
return; return;
} }
const labelSelector = `${APP_LABEL}=${appName}`; const labelSelector = podSelector !== undefined ? podSelector : `${APP_LABEL}=${appName}`;
try { try {
const kc = getKubeConfig(); const kc = getKubeConfig();
@ -63,7 +63,7 @@ export default async function handler(req, res) {
}); });
}); });
const stats = await pods.map(async (pod) => { const podStatsList = await Promise.all(pods.map(async (pod) => {
let depMem = 0; let depMem = 0;
let depCpu = 0; let depCpu = 0;
const podMetrics = await metricsApi.getPodMetrics(namespace, pod.metadata.name) const podMetrics = await metricsApi.getPodMetrics(namespace, pod.metadata.name)
@ -85,13 +85,15 @@ export default async function handler(req, res) {
mem: depMem, mem: depMem,
cpu: depCpu cpu: depCpu
}; };
}).reduce(async (finalStats, podStatPromise) => { }));
const podStats = await podStatPromise; const stats = {
return { mem: 0,
mem: finalStats.mem + podStats.mem, cpu: 0
cpu: finalStats.cpu + podStats.cpu }
}; podStatsList.forEach((podStat) => {
}); stats.mem += podStat.mem;
stats.cpu += podStat.cpu;
});
stats.cpuLimit = cpuLimit; stats.cpuLimit = cpuLimit;
stats.memLimit = memLimit; stats.memLimit = memLimit;
stats.cpuUsage = cpuLimit ? stats.cpu / cpuLimit : 0; stats.cpuUsage = cpuLimit ? stats.cpu / cpuLimit : 0;

View File

@ -7,7 +7,7 @@ const logger = createLogger("kubernetesStatusService");
export default async function handler(req, res) { export default async function handler(req, res) {
const APP_LABEL = "app.kubernetes.io/name"; const APP_LABEL = "app.kubernetes.io/name";
const { service } = req.query; const { service, podSelector } = req.query;
const [namespace, appName] = service; const [namespace, appName] = service;
if (!namespace && !appName) { if (!namespace && !appName) {
@ -16,8 +16,8 @@ export default async function handler(req, res) {
}); });
return; return;
} }
const labelSelector = `${APP_LABEL}=${appName}`; const labelSelector = podSelector !== undefined ? podSelector : `${APP_LABEL}=${appName}`;
logger.info("labelSelector %s/%s = %s", namespace, appName, labelSelector);
try { try {
const kc = getKubeConfig(); const kc = getKubeConfig();
if (!kc) { if (!kc) {
@ -47,10 +47,14 @@ export default async function handler(req, res) {
}); });
return; return;
} }
const someReady = pods.find(pod => pod.status.phase === "Running");
// at least one pod must be in the "Running" phase, otherwise its "down" const allReady = pods.every((pod) => pod.status.phase === "Running");
const runningPod = pods.find(pod => pod.status.phase === "Running"); let status = "down";
const status = runningPod ? "running" : "down"; if (allReady) {
status = "running";
} else if (someReady) {
status = "partial";
}
res.status(200).json({ res.status(200).json({
status status
}); });

View File

@ -144,13 +144,14 @@ export async function servicesFromKubernetes() {
app: ingress.metadata.name, app: ingress.metadata.name,
namespace: ingress.metadata.namespace, namespace: ingress.metadata.namespace,
href: getUrlFromIngress(ingress), href: getUrlFromIngress(ingress),
name: ingress.metadata.annotations['homepage/name'] || ingress.metadata.name, name: ingress.metadata.annotations['gethomepage.dev/name'] || ingress.metadata.name,
group: ingress.metadata.annotations['homepage/group'] || "Kubernetes", group: ingress.metadata.annotations['gethomepage.dev/group'] || "Kubernetes",
icon: ingress.metadata.annotations['homepage/icon'] || '', icon: ingress.metadata.annotations['gethomepage.dev/icon'] || '',
description: ingress.metadata.annotations['homepage/description'] || '' description: ingress.metadata.annotations['gethomepage.dev/description'] || '',
podSelector: ingress.metadata.annotations['gethomepage.dev/pod-selector'] || ''
}; };
Object.keys(ingress.metadata.annotations).forEach((annotation) => { Object.keys(ingress.metadata.annotations).forEach((annotation) => {
if (annotation.startsWith("homepage/widget/")) { if (annotation.startsWith("gethomepage.dev//widget/")) {
shvl.set(constructedService, annotation.replace("homepage/widget/", ""), ingress.metadata.annotations[annotation]); shvl.set(constructedService, annotation.replace("homepage/widget/", ""), ingress.metadata.annotations[annotation]);
} }
}); });
@ -202,9 +203,10 @@ export function cleanServiceGroups(groups) {
container, container,
currency, // coinmarketcap widget currency, // coinmarketcap widget
symbols, symbols,
defaultinterval defaultinterval,
namespace, // kubernetes widget namespace, // kubernetes widget
app app,
podSelector
} = cleanedService.widget; } = cleanedService.widget;
cleanedService.widget = { cleanedService.widget = {
@ -225,6 +227,7 @@ export function cleanServiceGroups(groups) {
if (type === "kubernetes") { if (type === "kubernetes") {
if (namespace) cleanedService.widget.namespace = namespace; if (namespace) cleanedService.widget.namespace = namespace;
if (app) cleanedService.widget.app = app; if (app) cleanedService.widget.app = app;
if (podSelector) cleanedService.widget.podSelector = podSelector;
} }
} }

View File

@ -8,12 +8,13 @@ export default function Component({ service }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { widget } = service; const { widget } = service;
const podSelectorString = service.podSelector !== undefined ? `podSelector=${widget.podSelector}` : "";
const { data: statusData, error: statusError } = useSWR( const { data: statusData, error: statusError } = useSWR(
`/api/kubernetes/status/${widget.namespace}/${widget.app}`); `/api/kubernetes/status/${widget.namespace}/${widget.app}?${podSelectorString}`);
const { data: statsData, error: statsError } = useSWR( const { data: statsData, error: statsError } = useSWR(
`/api/kubernetes/stats/${widget.namespace}/${widget.app}`); `/api/kubernetes/stats/${widget.namespace}/${widget.app}?${podSelectorString}`);
if (statsError || statusError) { if (statsError || statusError) {
return <Container error={t("widget.api_error")} />; return <Container error={t("widget.api_error")} />;