feat: Enhance Kubernetes Dashboard with resource consumption insights and YAML views

- Added top CPU and memory pod consumers to the Kubernetes cluster overview.
- Implemented node pressure indicators for memory, disk, and PID pressure.
- Introduced recent warning events section in the cluster overview.
- Added YAML tab for viewing resource specifications in Job, Namespace, Node, Pod, PVC, PV, and StatefulSet details.
- Updated side menu to display resource counts for various Kubernetes objects.
- Enhanced node detail view with roles, internal IP, and pressure conditions.
- Improved error handling and loading states in the YAML tab component.
This commit is contained in:
Nawaz Dhandala
2026-03-19 22:33:41 +00:00
parent b10d2f458e
commit 4f67228eaf
21 changed files with 1573 additions and 127 deletions

View File

@@ -24,20 +24,26 @@ const KubernetesEventsTab: FunctionComponent<ComponentProps> = (
const [events, setEvents] = useState<Array<KubernetesEvent>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [typeFilter, setTypeFilter] = useState<"all" | "warning" | "normal">(
"all",
);
useEffect(() => {
const fetchEvents: () => Promise<void> = async (): Promise<void> => {
setIsLoading(true);
try {
const result: Array<KubernetesEvent> = await fetchK8sEventsForResource({
clusterIdentifier: props.clusterIdentifier,
resourceKind: props.resourceKind,
resourceName: props.resourceName,
namespace: props.namespace,
});
const result: Array<KubernetesEvent> =
await fetchK8sEventsForResource({
clusterIdentifier: props.clusterIdentifier,
resourceKind: props.resourceKind,
resourceName: props.resourceName,
namespace: props.namespace,
});
setEvents(result);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch events");
setError(
err instanceof Error ? err.message : "Failed to fetch events",
);
}
setIsLoading(false);
};
@@ -67,55 +73,128 @@ const KubernetesEventsTab: FunctionComponent<ComponentProps> = (
);
}
const warningCount: number = events.filter(
(e: KubernetesEvent) => e.type.toLowerCase() === "warning",
).length;
const normalCount: number = events.length - warningCount;
const filteredEvents: Array<KubernetesEvent> = events.filter(
(e: KubernetesEvent) => {
if (typeFilter === "warning") {
return e.type.toLowerCase() === "warning";
}
if (typeFilter === "normal") {
return e.type.toLowerCase() !== "warning";
}
return true;
},
);
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Time
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Type
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Reason
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Message
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{events.map((event: KubernetesEvent, index: number) => {
const isWarning: boolean = event.type.toLowerCase() === "warning";
return (
<tr key={index} className={isWarning ? "bg-yellow-50" : ""}>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
{event.timestamp}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
isWarning
? "bg-yellow-100 text-yellow-800"
: "bg-green-100 text-green-800"
}`}
<div>
{/* Summary and Filters */}
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<div className="text-sm text-gray-600">
<span className="font-medium">{events.length}</span> events
{warningCount > 0 && (
<span>
{" "}
(<span className="text-yellow-700 font-medium">
{warningCount}
</span>{" "}
warning{warningCount !== 1 ? "s" : ""},{" "}
<span className="text-green-700 font-medium">{normalCount}</span>{" "}
normal)
</span>
)}
</div>
<div className="flex gap-1">
{(["all", "warning", "normal"] as const).map(
(filter: "all" | "warning" | "normal") => {
return (
<button
key={filter}
onClick={() => {
setTypeFilter(filter);
}}
className={`px-3 py-1 text-xs rounded-full font-medium transition-colors ${
typeFilter === filter
? "bg-indigo-100 text-indigo-800"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{filter === "all"
? "All"
: filter === "warning"
? `Warnings (${warningCount})`
: `Normal (${normalCount})`}
</button>
);
},
)}
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Time
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Type
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Reason
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Message
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredEvents.map(
(event: KubernetesEvent, index: number) => {
const isWarning: boolean =
event.type.toLowerCase() === "warning";
return (
<tr
key={index}
className={isWarning ? "bg-yellow-50" : ""}
>
{event.type}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{event.reason}
</td>
<td className="px-4 py-3 text-sm text-gray-500 max-w-lg">
{event.message}
</td>
</tr>
);
})}
</tbody>
</table>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
{event.timestamp}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
isWarning
? "bg-yellow-100 text-yellow-800"
: "bg-green-100 text-green-800"
}`}
>
{event.type}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{event.reason}
</td>
<td className="px-4 py-3 text-sm text-gray-500 max-w-lg">
{event.message}
</td>
</tr>
);
},
)}
</tbody>
</table>
</div>
{filteredEvents.length === 0 && (
<div className="text-gray-500 text-sm p-4 text-center">
No {typeFilter} events found.
</div>
)}
</div>
);
};

View File

@@ -13,7 +13,7 @@ const KubernetesMetricsTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6);
const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -1);
const startAndEndDate: InBetween<Date> = new InBetween(startDate, endDate);
const [metricViewData, setMetricViewData] = useState<MetricViewData>({

View File

@@ -1,10 +1,72 @@
import React, { FunctionComponent, ReactElement } from "react";
import React, { FunctionComponent, ReactElement, useState } from "react";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import DictionaryOfStringsViewer from "Common/UI/Components/Dictionary/DictionaryOfStingsViewer";
import { KubernetesCondition } from "../../Pages/Kubernetes/Utils/KubernetesObjectParser";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
// Conditions where "True" means something is wrong
const negativeConditionTypes: Array<string> = [
"MemoryPressure",
"DiskPressure",
"PIDPressure",
"NetworkUnavailable",
];
function isConditionBad(condition: KubernetesCondition): boolean {
const isNegativeType: boolean = negativeConditionTypes.includes(
condition.type,
);
if (isNegativeType) {
return condition.status === "True";
}
// For positive conditions (Ready, Initialized, etc.), False is bad
return condition.status === "False";
}
function getConditionStatusColor(condition: KubernetesCondition): string {
const isNegativeType: boolean = negativeConditionTypes.includes(
condition.type,
);
if (condition.status === "True") {
return isNegativeType
? "bg-red-100 text-red-800"
: "bg-green-100 text-green-800";
}
if (condition.status === "False") {
return isNegativeType
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800";
}
return "bg-yellow-100 text-yellow-800";
}
function formatRelativeTime(timestamp: string): string {
if (!timestamp) {
return "-";
}
const date: Date = new Date(timestamp);
const now: Date = new Date();
const diffMs: number = now.getTime() - date.getTime();
if (diffMs < 0) {
return timestamp;
}
const diffSec: number = Math.floor(diffMs / 1000);
if (diffSec < 60) {
return `${diffSec}s ago`;
}
const diffMin: number = Math.floor(diffSec / 60);
if (diffMin < 60) {
return `${diffMin}m ago`;
}
const diffHrs: number = Math.floor(diffMin / 60);
if (diffHrs < 24) {
return `${diffHrs}h ago`;
}
const diffDays: number = Math.floor(diffHrs / 24);
return `${diffDays}d ago`;
}
export interface SummaryField {
title: string;
value: string | ReactElement;
@@ -20,6 +82,37 @@ export interface ComponentProps {
emptyMessage?: string | undefined;
}
const ExpandableMessage: FunctionComponent<{ message: string }> = (
msgProps: { message: string },
): ReactElement => {
const [expanded, setExpanded] = useState<boolean>(false);
const isLong: boolean = msgProps.message.length > 80;
if (!msgProps.message || msgProps.message === "-") {
return <span className="text-gray-400">-</span>;
}
if (!isLong) {
return <span className="text-gray-600">{msgProps.message}</span>;
}
return (
<div>
<span className="text-gray-600">
{expanded ? msgProps.message : msgProps.message.substring(0, 80) + "..."}
</span>
<button
onClick={() => {
setExpanded(!expanded);
}}
className="ml-1 text-xs text-indigo-600 hover:text-indigo-800"
>
{expanded ? "Less" : "More"}
</button>
</div>
);
};
const KubernetesOverviewTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
@@ -105,20 +198,18 @@ const KubernetesOverviewTab: FunctionComponent<ComponentProps> = (
<tbody className="bg-white divide-y divide-gray-200">
{props.conditions.map(
(condition: KubernetesCondition, index: number) => {
const isBad: boolean = isConditionBad(condition);
return (
<tr key={index}>
<tr
key={index}
className={isBad ? "bg-red-50" : ""}
>
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
{condition.type}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">
<span
className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${
condition.status === "True"
? "bg-green-50 text-green-700"
: condition.status === "False"
? "bg-red-50 text-red-700"
: "bg-gray-50 text-gray-700"
}`}
className={`inline-flex px-2 py-0.5 text-xs font-semibold rounded-full ${getConditionStatusColor(condition)}`}
>
{condition.status}
</span>
@@ -126,11 +217,19 @@ const KubernetesOverviewTab: FunctionComponent<ComponentProps> = (
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
{condition.reason || "-"}
</td>
<td className="px-4 py-3 text-sm text-gray-600 max-w-md truncate">
{condition.message || "-"}
<td className="px-4 py-3 text-sm max-w-md">
<ExpandableMessage
message={condition.message || "-"}
/>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
{condition.lastTransitionTime || "-"}
<span
title={condition.lastTransitionTime || ""}
>
{formatRelativeTime(
condition.lastTransitionTime,
)}
</span>
</td>
</tr>
);

View File

@@ -0,0 +1,206 @@
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import Card from "Common/UI/Components/Card/Card";
import CodeEditor from "Common/UI/Components/CodeEditor/CodeEditor";
import CodeType from "Common/Types/Code/CodeType";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import {
fetchLatestK8sObject,
KubernetesObjectType,
} from "../../Pages/Kubernetes/Utils/KubernetesObjectFetcher";
import Button, { ButtonStyleType } from "Common/UI/Components/Button/Button";
import IconProp from "Common/Types/Icon/IconProp";
export interface ComponentProps {
clusterIdentifier: string;
resourceType: string;
resourceName: string;
namespace?: string | undefined;
}
/**
* Convert a JavaScript object to YAML string.
*/
function toYaml(obj: unknown, indent: number = 0): string {
const prefix: string = " ".repeat(indent);
if (obj === null || obj === undefined) {
return "null";
}
if (typeof obj === "string") {
// Quote strings that contain special chars or look like numbers
if (
obj.includes(":") ||
obj.includes("#") ||
obj.includes("\n") ||
obj.includes("'") ||
obj.includes('"') ||
obj === "" ||
obj === "true" ||
obj === "false" ||
obj === "null" ||
/^\d/.test(obj)
) {
return `"${obj.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
return obj;
}
if (typeof obj === "number" || typeof obj === "boolean") {
return String(obj);
}
if (Array.isArray(obj)) {
if (obj.length === 0) {
return "[]";
}
const lines: Array<string> = [];
for (const item of obj) {
if (typeof item === "object" && item !== null && !Array.isArray(item)) {
const entries: Array<[string, unknown]> = Object.entries(
item as Record<string, unknown>,
);
if (entries.length > 0) {
const [firstKey, firstVal] = entries[0]!;
lines.push(
`${prefix}- ${firstKey}: ${toYaml(firstVal, indent + 2)}`,
);
for (let i: number = 1; i < entries.length; i++) {
const [key, val] = entries[i]!;
lines.push(
`${prefix} ${key}: ${toYaml(val, indent + 2)}`,
);
}
} else {
lines.push(`${prefix}- {}`);
}
} else {
lines.push(`${prefix}- ${toYaml(item, indent + 1)}`);
}
}
return "\n" + lines.join("\n");
}
if (typeof obj === "object") {
const record: Record<string, unknown> = obj as Record<string, unknown>;
const keys: Array<string> = Object.keys(record);
if (keys.length === 0) {
return "{}";
}
const lines: Array<string> = [];
for (const key of keys) {
const val: unknown = record[key];
if (
val !== null &&
val !== undefined &&
typeof val === "object" &&
!Array.isArray(val) &&
Object.keys(val as Record<string, unknown>).length > 0
) {
lines.push(`${prefix}${key}:`);
lines.push(toYaml(val, indent + 1));
} else if (Array.isArray(val) && val.length > 0) {
lines.push(`${prefix}${key}:${toYaml(val, indent + 1)}`);
} else {
lines.push(`${prefix}${key}: ${toYaml(val, indent + 1)}`);
}
}
return lines.join("\n");
}
return String(obj);
}
const KubernetesYamlTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [yamlContent, setYamlContent] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [copied, setCopied] = useState<boolean>(false);
useEffect(() => {
const fetchData: () => Promise<void> = async (): Promise<void> => {
setIsLoading(true);
setError("");
try {
const result: KubernetesObjectType | null =
await fetchLatestK8sObject({
clusterIdentifier: props.clusterIdentifier,
resourceType: props.resourceType,
resourceName: props.resourceName,
namespace: props.namespace,
});
if (result) {
const yaml: string = toYaml(result);
setYamlContent(yaml);
} else {
setYamlContent("");
}
} catch {
setError("Failed to fetch resource data.");
} finally {
setIsLoading(false);
}
};
fetchData();
}, [
props.clusterIdentifier,
props.resourceType,
props.resourceName,
props.namespace,
]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (!yamlContent) {
return (
<ErrorMessage message="No resource spec data available. Ensure the kubernetes-agent has resourceSpecs.enabled set to true in the Helm values." />
);
}
return (
<Card
title="Resource Specification"
description="Full resource specification as collected by the kubernetes-agent."
buttons={[
{
title: copied ? "Copied!" : "Copy",
buttonStyle: ButtonStyleType.NORMAL,
icon: IconProp.Copy,
onClick: () => {
navigator.clipboard.writeText(yamlContent);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
},
},
]}
>
<div className="-mt-2">
<CodeEditor
type={CodeType.YAML}
value={yamlContent}
readOnly={true}
showLineNumbers={true}
/>
</div>
</Card>
);
};
export default KubernetesYamlTab;

View File

@@ -426,6 +426,147 @@ export async function fetchK8sEventsForResource(options: {
}
}
/**
* Fetch recent warning events for an entire cluster.
*/
export async function fetchClusterWarningEvents(options: {
clusterIdentifier: string;
limit?: number | undefined;
}): Promise<Array<KubernetesEvent>> {
const projectId: string | undefined =
ProjectUtil.getCurrentProjectId()?.toString();
if (!projectId) {
return [];
}
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -24);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const eventsQueryOptions: any = {
modelType: Log,
query: {
projectId: projectId,
time: new InBetween<Date>(startDate, endDate),
attributes: {
"logAttributes.event.domain": "k8s",
"logAttributes.k8s.resource.name": "events",
},
},
limit: 500,
skip: 0,
select: {
time: true,
body: true,
attributes: true,
},
sort: {
time: SortOrder.Descending,
},
requestOptions: {},
};
const listResult: ListResult<Log> =
await AnalyticsModelAPI.getList<Log>(eventsQueryOptions);
const events: Array<KubernetesEvent> = [];
const maxEvents: number = options.limit || 10;
for (const log of listResult.data) {
if (events.length >= maxEvents) {
break;
}
const attrs: JSONObject = log.attributes || {};
if (
attrs["resource.k8s.cluster.name"] !== options.clusterIdentifier &&
attrs["k8s.cluster.name"] !== options.clusterIdentifier
) {
continue;
}
if (typeof log.body !== "string") {
continue;
}
let bodyObj: JSONObject | null = null;
try {
bodyObj = JSON.parse(log.body) as JSONObject;
} catch {
continue;
}
const topKvList: JSONObject | undefined = bodyObj["kvlistValue"] as
| JSONObject
| undefined;
if (!topKvList) {
continue;
}
const objectVal: string | JSONObject | null = getKvValue(
topKvList,
"object",
);
if (!objectVal || typeof objectVal === "string") {
continue;
}
const objectKvList: JSONObject = objectVal;
const eventType: string =
getKvStringValue(objectKvList, "type") || "";
// Only include Warning events
if (eventType !== "Warning") {
continue;
}
const reason: string =
getKvStringValue(objectKvList, "reason") || "";
const note: string =
getKvStringValue(objectKvList, "note") || "";
const regardingKind: string =
getKvStringValue(
getKvValue(objectKvList, "regarding") as
| JSONObject
| undefined,
"kind",
) || "";
const regardingName: string =
getKvStringValue(
getKvValue(objectKvList, "regarding") as
| JSONObject
| undefined,
"name",
) || "";
const regardingNamespace: string =
getKvStringValue(
getKvValue(objectKvList, "regarding") as
| JSONObject
| undefined,
"namespace",
) || "";
events.push({
timestamp: log.time
? OneUptimeDate.getDateAsLocalFormattedString(log.time)
: "",
type: eventType,
reason: reason || "Unknown",
objectKind: regardingKind || "Unknown",
objectName: regardingName || "Unknown",
namespace: regardingNamespace || "default",
message: note || "",
});
}
return events;
} catch {
return [];
}
}
/**
* Fetch application logs for a pod/container from the Log table.
* These come from the filelog receiver (not k8sobjects).

View File

@@ -232,6 +232,7 @@ export interface KubernetesPodObject {
phase: string;
podIP: string;
hostIP: string;
qosClass: string;
conditions: Array<KubernetesCondition>;
containerStatuses: Array<KubernetesContainerStatus>;
initContainerStatuses: Array<KubernetesContainerStatus>;
@@ -779,6 +780,7 @@ export function parsePodObject(
let phase: string = "";
let podIP: string = "";
let hostIP: string = "";
let qosClass: string = "";
let conditions: Array<KubernetesCondition> = [];
let containerStatuses: Array<KubernetesContainerStatus> = [];
let initContainerStatuses: Array<KubernetesContainerStatus> = [];
@@ -787,6 +789,7 @@ export function parsePodObject(
phase = getKvStringValue(statusKv, "phase");
podIP = getKvStringValue(statusKv, "podIP");
hostIP = getKvStringValue(statusKv, "hostIP");
qosClass = getKvStringValue(statusKv, "qosClass");
const condArray: string | JSONObject | null = getKvValue(
statusKv,
@@ -828,6 +831,7 @@ export function parsePodObject(
phase,
podIP,
hostIP,
qosClass,
conditions,
containerStatuses,
initContainerStatuses,

View File

@@ -28,6 +28,7 @@ import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetri
import { KubernetesCronJobObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils";
import KubernetesYamlTab from "../../../Components/Kubernetes/KubernetesYamlTab";
const KubernetesClusterCronJobDetail: FunctionComponent<
PageComponentProps
@@ -252,6 +253,17 @@ const KubernetesClusterCronJobDetail: FunctionComponent<
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="cronjobs"
resourceName={cronJobName}
namespace={cronJobObject?.metadata.namespace}
/>
),
},
];
return <Tabs tabs={tabs} onTabChange={() => {}} />;

View File

@@ -28,6 +28,7 @@ import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetri
import { KubernetesDaemonSetObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils";
import KubernetesYamlTab from "../../../Components/Kubernetes/KubernetesYamlTab";
const KubernetesClusterDaemonSetDetail: FunctionComponent<
PageComponentProps
@@ -244,6 +245,17 @@ const KubernetesClusterDaemonSetDetail: FunctionComponent<
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="daemonsets"
resourceName={daemonSetName}
namespace={objectData?.metadata.namespace}
/>
),
},
];
return <Tabs tabs={tabs} onTabChange={() => {}} />;

View File

@@ -28,6 +28,7 @@ import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetri
import { KubernetesDeploymentObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils";
import KubernetesYamlTab from "../../../Components/Kubernetes/KubernetesYamlTab";
const KubernetesClusterDeploymentDetail: FunctionComponent<
PageComponentProps
@@ -173,30 +174,84 @@ const KubernetesClusterDeploymentDetail: FunctionComponent<
];
if (objectData) {
const desired: number = objectData.spec.replicas;
const ready: number = objectData.status.readyReplicas ?? 0;
const available: number = objectData.status.availableReplicas ?? 0;
const unavailable: number = objectData.status.unavailableReplicas ?? 0;
const isFullyRolledOut: boolean =
ready === desired && unavailable === 0;
summaryFields.push(
{
title: "Namespace",
value: objectData.metadata.namespace || "default",
},
{
title: "Replicas",
value: String(objectData.spec.replicas ?? "N/A"),
title: "Rollout Status",
value: (
<div>
<div className="flex items-center gap-2 mb-1">
<span
className={`inline-flex px-2 py-0.5 text-xs font-semibold rounded-full ${
isFullyRolledOut
? "bg-green-100 text-green-800"
: "bg-yellow-100 text-yellow-800"
}`}
>
{isFullyRolledOut ? "Complete" : "In Progress"}
</span>
<span className="text-sm text-gray-600">
{ready}/{desired} ready
</span>
</div>
<div className="w-32 bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${isFullyRolledOut ? "bg-green-500" : "bg-yellow-500"}`}
style={{
width: `${desired > 0 ? (ready / desired) * 100 : 0}%`,
}}
/>
</div>
</div>
),
},
{
title: "Desired Replicas",
value: String(desired),
},
{
title: "Ready Replicas",
value: String(objectData.status.readyReplicas ?? "N/A"),
value: String(ready),
},
{
title: "Available Replicas",
value: String(objectData.status.availableReplicas ?? "N/A"),
title: "Available",
value: String(available),
},
);
if (unavailable > 0) {
summaryFields.push({
title: "Unavailable",
value: (
<span className="text-red-700 font-medium">
{String(unavailable)}
</span>
),
});
}
summaryFields.push(
{
title: "Strategy",
value: objectData.spec.strategy || "N/A",
},
{
title: "Created",
value: objectData.metadata.creationTimestamp || "N/A",
value: objectData.metadata.creationTimestamp
? KubernetesResourceUtils.formatAge(
objectData.metadata.creationTimestamp,
)
: "N/A",
},
);
}
@@ -241,6 +296,17 @@ const KubernetesClusterDeploymentDetail: FunctionComponent<
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="deployments"
resourceName={deploymentName}
namespace={objectData?.metadata.namespace}
/>
),
},
];
return <Tabs tabs={tabs} onTabChange={() => {}} />;

View File

@@ -36,6 +36,11 @@ const KubernetesClusterEvents: FunctionComponent<
const [events, setEvents] = useState<Array<KubernetesEvent>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [typeFilter, setTypeFilter] = useState<"all" | "warning" | "normal">(
"all",
);
const [namespaceFilter, setNamespaceFilter] = useState<string>("all");
const [searchText, setSearchText] = useState<string>("");
const fetchData: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
@@ -201,17 +206,144 @@ const KubernetesClusterEvents: FunctionComponent<
return <ErrorMessage message="Cluster not found." />;
}
// Compute filter options
const namespaces: Array<string> = Array.from(
new Set(events.map((e: KubernetesEvent) => e.namespace)),
).sort();
const warningCount: number = events.filter(
(e: KubernetesEvent) => e.type.toLowerCase() === "warning",
).length;
const normalCount: number = events.length - warningCount;
// Apply filters
const filteredEvents: Array<KubernetesEvent> = events.filter(
(e: KubernetesEvent) => {
if (
typeFilter === "warning" &&
e.type.toLowerCase() !== "warning"
) {
return false;
}
if (
typeFilter === "normal" &&
e.type.toLowerCase() === "warning"
) {
return false;
}
if (namespaceFilter !== "all" && e.namespace !== namespaceFilter) {
return false;
}
if (searchText.trim()) {
const search: string = searchText.toLowerCase();
return (
e.message.toLowerCase().includes(search) ||
e.reason.toLowerCase().includes(search) ||
e.objectName.toLowerCase().includes(search) ||
e.objectKind.toLowerCase().includes(search)
);
}
return true;
},
);
return (
<Fragment>
<Card
title="Kubernetes Events"
description="Events from the last 24 hours collected by the k8sobjects receiver. Warning events may indicate issues that need attention."
description="Events from the last 24 hours collected by the k8sobjects receiver."
>
{/* Event Summary Banner */}
<div className="flex items-center gap-4 px-4 pt-4 pb-2">
<div className="text-sm text-gray-600">
<span className="font-semibold text-gray-900">
{events.length}
</span>{" "}
total events
</div>
{warningCount > 0 && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
{warningCount} Warning{warningCount !== 1 ? "s" : ""}
</span>
)}
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
{normalCount} Normal
</span>
</div>
{/* Filters Row */}
<div className="flex flex-wrap items-center gap-3 px-4 py-3 border-b border-gray-200">
{/* Type Filter Buttons */}
<div className="flex gap-1">
{(["all", "warning", "normal"] as const).map(
(filter: "all" | "warning" | "normal") => {
return (
<button
key={filter}
onClick={() => {
setTypeFilter(filter);
}}
className={`px-3 py-1.5 text-xs rounded-md font-medium transition-colors ${
typeFilter === filter
? "bg-indigo-100 text-indigo-800 border border-indigo-200"
: "bg-white text-gray-600 border border-gray-200 hover:bg-gray-50"
}`}
>
{filter === "all"
? "All Types"
: filter === "warning"
? "Warnings"
: "Normal"}
</button>
);
},
)}
</div>
{/* Namespace Filter */}
<select
value={namespaceFilter}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
setNamespaceFilter(e.target.value);
}}
className="px-3 py-1.5 text-xs rounded-md border border-gray-200 bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
<option value="all">All Namespaces</option>
{namespaces.map((ns: string) => {
return (
<option key={ns} value={ns}>
{ns}
</option>
);
})}
</select>
{/* Text Search */}
<input
type="text"
placeholder="Search events..."
value={searchText}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(e.target.value);
}}
className="px-3 py-1.5 text-xs rounded-md border border-gray-200 bg-white text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500 w-64"
/>
{/* Results Count */}
<span className="text-xs text-gray-500 ml-auto">
Showing {filteredEvents.length} of {events.length}
</span>
</div>
{events.length === 0 ? (
<p className="text-gray-500 text-sm">
<p className="text-gray-500 text-sm p-4">
No Kubernetes events found in the last 24 hours. Events will appear
here once the kubernetes-agent is sending data.
</p>
) : filteredEvents.length === 0 ? (
<p className="text-gray-500 text-sm p-4 text-center">
No events match the current filters.
</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
@@ -238,40 +370,47 @@ const KubernetesClusterEvents: FunctionComponent<
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{events.map((event: KubernetesEvent, index: number) => {
const isWarning: boolean =
event.type.toLowerCase() === "warning";
return (
<tr key={index} className={isWarning ? "bg-yellow-50" : ""}>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
{event.timestamp}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
isWarning
? "bg-yellow-100 text-yellow-800"
: "bg-green-100 text-green-800"
}`}
>
{event.type}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{event.reason}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{event.objectKind}/{event.objectName}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
{event.namespace}
</td>
<td className="px-4 py-3 text-sm text-gray-500 max-w-md truncate">
{event.message}
</td>
</tr>
);
})}
{filteredEvents.map(
(event: KubernetesEvent, index: number) => {
const isWarning: boolean =
event.type.toLowerCase() === "warning";
return (
<tr
key={index}
className={isWarning ? "bg-yellow-50" : ""}
>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
{event.timestamp}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
isWarning
? "bg-yellow-100 text-yellow-800"
: "bg-green-100 text-green-800"
}`}
>
{event.type}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{event.reason}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{event.objectKind}/{event.objectName}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">
<span className="inline-flex px-2 py-0.5 text-xs rounded bg-blue-50 text-blue-700">
{event.namespace}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-500 max-w-md">
{event.message}
</td>
</tr>
);
},
)}
</tbody>
</table>
</div>

View File

@@ -27,12 +27,15 @@ import KubernetesResourceUtils, {
} from "../Utils/KubernetesResourceUtils";
import {
fetchK8sObjectsBatch,
fetchClusterWarningEvents,
KubernetesObjectType,
KubernetesEvent,
} from "../Utils/KubernetesObjectFetcher";
import {
KubernetesPodObject,
KubernetesNodeObject,
} from "../Utils/KubernetesObjectParser";
import OneUptimeDate from "Common/Types/Date";
interface ResourceLink {
title: string;
@@ -64,6 +67,18 @@ const KubernetesClusterOverview: FunctionComponent<
const [clusterHealth, setClusterHealth] = useState<
"Healthy" | "Degraded" | "Unhealthy"
>("Healthy");
const [topCpuPods, setTopCpuPods] = useState<Array<KubernetesResource>>([]);
const [topMemoryPods, setTopMemoryPods] = useState<
Array<KubernetesResource>
>([]);
const [recentWarnings, setRecentWarnings] = useState<
Array<KubernetesEvent>
>([]);
const [nodePressure, setNodePressure] = useState<{
memoryPressure: number;
diskPressure: number;
pidPressure: number;
}>({ memoryPressure: 0, diskPressure: 0, pidPressure: 0 });
const fetchCluster: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
@@ -94,10 +109,11 @@ const KubernetesClusterOverview: FunctionComponent<
resourceNameAttribute: "resource.k8s.node.name",
namespaceAttribute: "resource.k8s.node.name",
}),
KubernetesResourceUtils.fetchResourceList({
KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: item.clusterIdentifier,
metricName: "k8s.pod.cpu.utilization",
resourceNameAttribute: "resource.k8s.pod.name",
memoryMetricName: "k8s.pod.memory.usage",
}),
KubernetesResourceUtils.fetchResourceList({
clusterIdentifier: item.clusterIdentifier,
@@ -111,6 +127,32 @@ const KubernetesClusterOverview: FunctionComponent<
setPodCount(pods.length);
setNamespaceCount(namespaces.length);
// Top resource consumers
const sortedByCpu: Array<KubernetesResource> = [...pods]
.filter(
(p: KubernetesResource) =>
p.cpuUtilization !== null && p.cpuUtilization !== undefined,
)
.sort(
(a: KubernetesResource, b: KubernetesResource) =>
(b.cpuUtilization ?? 0) - (a.cpuUtilization ?? 0),
)
.slice(0, 5);
setTopCpuPods(sortedByCpu);
const sortedByMemory: Array<KubernetesResource> = [...pods]
.filter(
(p: KubernetesResource) =>
p.memoryUsageBytes !== null &&
p.memoryUsageBytes !== undefined,
)
.sort(
(a: KubernetesResource, b: KubernetesResource) =>
(b.memoryUsageBytes ?? 0) - (a.memoryUsageBytes ?? 0),
)
.slice(0, 5);
setTopMemoryPods(sortedByMemory);
// Fetch pod and node objects for health status
try {
const [podObjects, nodeObjects]: [
@@ -149,9 +191,12 @@ const KubernetesClusterOverview: FunctionComponent<
}
setPodHealthSummary({ running, pending, failed, succeeded });
// Calculate node health
// Calculate node health and pressure
let ready: number = 0;
let notReady: number = 0;
let memPressure: number = 0;
let diskPressure: number = 0;
let pidPressure: number = 0;
for (const nodeObj of nodeObjects.values()) {
const node: KubernetesNodeObject =
@@ -165,8 +210,34 @@ const KubernetesClusterOverview: FunctionComponent<
} else {
notReady++;
}
// Check pressure conditions
for (const cond of node.status.conditions) {
if (
cond.type === "MemoryPressure" &&
cond.status === "True"
) {
memPressure++;
}
if (
cond.type === "DiskPressure" &&
cond.status === "True"
) {
diskPressure++;
}
if (
cond.type === "PIDPressure" &&
cond.status === "True"
) {
pidPressure++;
}
}
}
setNodeHealthSummary({ ready, notReady });
setNodePressure({
memoryPressure: memPressure,
diskPressure: diskPressure,
pidPressure: pidPressure,
});
// Determine overall health
if (failed > 0 || notReady > 0) {
@@ -179,6 +250,18 @@ const KubernetesClusterOverview: FunctionComponent<
} catch {
// Health data is supplementary, don't fail
}
// Fetch recent warning events
try {
const warnings: Array<KubernetesEvent> =
await fetchClusterWarningEvents({
clusterIdentifier: item.clusterIdentifier,
limit: 5,
});
setRecentWarnings(warnings);
} catch {
// Warnings are supplementary
}
}
} catch (err) {
setError(API.getFriendlyMessage(err));
@@ -467,6 +550,249 @@ const KubernetesClusterOverview: FunctionComponent<
</div>
</Card>
{/* Node Pressure Indicators */}
{(nodePressure.memoryPressure > 0 ||
nodePressure.diskPressure > 0 ||
nodePressure.pidPressure > 0) && (
<div className="mb-5 rounded-lg border border-red-200 bg-red-50 p-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-semibold text-red-800">
Node Pressure Detected
</span>
</div>
<div className="flex gap-4 text-sm">
{nodePressure.memoryPressure > 0 && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
{nodePressure.memoryPressure} node
{nodePressure.memoryPressure > 1 ? "s" : ""}: Memory
Pressure
</span>
)}
{nodePressure.diskPressure > 0 && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
{nodePressure.diskPressure} node
{nodePressure.diskPressure > 1 ? "s" : ""}: Disk Pressure
</span>
)}
{nodePressure.pidPressure > 0 && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
{nodePressure.pidPressure} node
{nodePressure.pidPressure > 1 ? "s" : ""}: PID Pressure
</span>
)}
</div>
</div>
)}
{/* Pod Health Visual Breakdown */}
{podCount > 0 && (
<Card
title="Pod Health"
description="Distribution of pod statuses across the cluster."
>
<div className="p-4">
<div className="flex h-4 rounded-full overflow-hidden bg-gray-200 mb-3">
{podHealthSummary.running > 0 && (
<div
className="bg-green-500 h-full"
style={{
width: `${(podHealthSummary.running / podCount) * 100}%`,
}}
title={`${podHealthSummary.running} Running`}
/>
)}
{podHealthSummary.succeeded > 0 && (
<div
className="bg-blue-500 h-full"
style={{
width: `${(podHealthSummary.succeeded / podCount) * 100}%`,
}}
title={`${podHealthSummary.succeeded} Succeeded`}
/>
)}
{podHealthSummary.pending > 0 && (
<div
className="bg-yellow-500 h-full"
style={{
width: `${(podHealthSummary.pending / podCount) * 100}%`,
}}
title={`${podHealthSummary.pending} Pending`}
/>
)}
{podHealthSummary.failed > 0 && (
<div
className="bg-red-500 h-full"
style={{
width: `${(podHealthSummary.failed / podCount) * 100}%`,
}}
title={`${podHealthSummary.failed} Failed`}
/>
)}
</div>
<div className="flex gap-6 text-sm">
<div className="flex items-center gap-1.5">
<span className="inline-block w-3 h-3 rounded-full bg-green-500" />
<span className="text-gray-700">
Running ({podHealthSummary.running})
</span>
</div>
{podHealthSummary.succeeded > 0 && (
<div className="flex items-center gap-1.5">
<span className="inline-block w-3 h-3 rounded-full bg-blue-500" />
<span className="text-gray-700">
Succeeded ({podHealthSummary.succeeded})
</span>
</div>
)}
{podHealthSummary.pending > 0 && (
<div className="flex items-center gap-1.5">
<span className="inline-block w-3 h-3 rounded-full bg-yellow-500" />
<span className="text-gray-700">
Pending ({podHealthSummary.pending})
</span>
</div>
)}
{podHealthSummary.failed > 0 && (
<div className="flex items-center gap-1.5">
<span className="inline-block w-3 h-3 rounded-full bg-red-500" />
<span className="text-gray-700">
Failed ({podHealthSummary.failed})
</span>
</div>
)}
</div>
</div>
</Card>
)}
{/* Top Resource Consumers */}
{(topCpuPods.length > 0 || topMemoryPods.length > 0) && (
<Card
title="Top Resource Consumers"
description="Pods with the highest resource utilization."
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 p-4">
{/* Top CPU */}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-3">
Top CPU Usage
</h4>
<div className="space-y-2">
{topCpuPods.map(
(pod: KubernetesResource, index: number) => {
const pct: number = Math.min(
pod.cpuUtilization ?? 0,
100,
);
return (
<div key={index} className="flex items-center gap-3">
<div className="w-40 truncate text-sm text-gray-800 font-medium">
{pod.name}
</div>
<span className="inline-flex px-1.5 py-0.5 text-xs rounded bg-blue-50 text-blue-700">
{pod.namespace}
</span>
<div className="flex-1 bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${pct > 80 ? "bg-red-500" : pct > 60 ? "bg-yellow-500" : "bg-green-500"}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-gray-600 w-12 text-right">
{KubernetesResourceUtils.formatCpuValue(
pod.cpuUtilization,
)}
</span>
</div>
);
},
)}
</div>
</div>
{/* Top Memory */}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-3">
Top Memory Usage
</h4>
<div className="space-y-2">
{topMemoryPods.map(
(pod: KubernetesResource, index: number) => {
return (
<div key={index} className="flex items-center gap-3">
<div className="w-40 truncate text-sm text-gray-800 font-medium">
{pod.name}
</div>
<span className="inline-flex px-1.5 py-0.5 text-xs rounded bg-blue-50 text-blue-700">
{pod.namespace}
</span>
<div className="flex-1">
<span className="text-xs text-gray-600">
{KubernetesResourceUtils.formatMemoryValue(
pod.memoryUsageBytes,
)}
</span>
</div>
</div>
);
},
)}
</div>
</div>
</div>
</Card>
)}
{/* Recent Warning Events */}
{recentWarnings.length > 0 && (
<Card
title="Recent Warnings"
description="Latest warning events from the cluster."
>
<div className="p-4">
<div className="space-y-3">
{recentWarnings.map(
(event: KubernetesEvent, index: number) => {
return (
<div
key={index}
className="flex items-start gap-3 p-3 rounded-lg bg-yellow-50 border border-yellow-100"
>
<span className="inline-flex px-2 py-0.5 text-xs font-medium rounded bg-yellow-100 text-yellow-800 mt-0.5">
{event.reason}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm text-gray-800">
{event.message}
</div>
<div className="text-xs text-gray-500 mt-1">
{event.objectKind}/{event.objectName} in{" "}
{event.namespace} &middot; {event.timestamp}
</div>
</div>
</div>
);
},
)}
</div>
<div className="mt-3">
<span
onClick={() => {
Navigation.navigate(
RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS] as Route,
{ modelId: modelId },
),
);
}}
className="text-sm text-indigo-600 hover:text-indigo-800 cursor-pointer font-medium"
>
View All Events
</span>
</div>
</div>
</Card>
)}
{/* Cluster Details */}
<CardModelDetail<KubernetesCluster>
name="Cluster Details"

View File

@@ -28,6 +28,7 @@ import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetri
import { KubernetesJobObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils";
import KubernetesYamlTab from "../../../Components/Kubernetes/KubernetesYamlTab";
const KubernetesClusterJobDetail: FunctionComponent<
PageComponentProps
@@ -256,6 +257,17 @@ const KubernetesClusterJobDetail: FunctionComponent<
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="jobs"
resourceName={jobName}
namespace={jobObject?.metadata.namespace}
/>
),
},
];
return <Tabs tabs={tabs} onTabChange={() => {}} />;

View File

@@ -2,12 +2,22 @@ import { getKubernetesBreadcrumbs } from "../../../Utils/Breadcrumbs";
import { RouteUtil } from "../../../Utils/RouteMap";
import PageComponentProps from "../../PageComponentProps";
import SideMenu from "./SideMenu";
import { ResourceCounts } from "./SideMenu";
import ObjectID from "Common/Types/ObjectID";
import ModelPage from "Common/UI/Components/Page/ModelPage";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import React, { FunctionComponent, ReactElement } from "react";
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import { Outlet, useParams } from "react-router-dom";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
const KubernetesClusterViewLayout: FunctionComponent<
PageComponentProps
@@ -15,6 +25,105 @@ const KubernetesClusterViewLayout: FunctionComponent<
const { id } = useParams();
const modelId: ObjectID = new ObjectID(id || "");
const path: string = Navigation.getRoutePath(RouteUtil.getRoutes());
const [resourceCounts, setResourceCounts] = useState<
ResourceCounts | undefined
>(undefined);
useEffect(() => {
const fetchCounts: () => Promise<void> = async (): Promise<void> => {
try {
const cluster: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: { clusterIdentifier: true },
});
if (!cluster?.clusterIdentifier) {
return;
}
const ci: string = cluster.clusterIdentifier;
// Fetch counts for key resources in parallel
const [
nodes,
pods,
namespaces,
deployments,
statefulSets,
daemonSets,
jobs,
cronJobs,
containers,
]: Array<Array<KubernetesResource>> = await Promise.all([
KubernetesResourceUtils.fetchResourceList({
clusterIdentifier: ci,
metricName: "k8s.node.cpu.utilization",
resourceNameAttribute: "resource.k8s.node.name",
namespaceAttribute: "resource.k8s.node.name",
}),
KubernetesResourceUtils.fetchResourceList({
clusterIdentifier: ci,
metricName: "k8s.pod.cpu.utilization",
resourceNameAttribute: "resource.k8s.pod.name",
}),
KubernetesResourceUtils.fetchResourceList({
clusterIdentifier: ci,
metricName: "k8s.pod.cpu.utilization",
resourceNameAttribute: "resource.k8s.namespace.name",
namespaceAttribute: "resource.k8s.namespace.name",
}),
KubernetesResourceUtils.fetchResourceList({
clusterIdentifier: ci,
metricName: "k8s.deployment.desired",
resourceNameAttribute: "resource.k8s.deployment.name",
}),
KubernetesResourceUtils.fetchResourceList({
clusterIdentifier: ci,
metricName: "k8s.statefulset.desired_pods",
resourceNameAttribute: "resource.k8s.statefulset.name",
}),
KubernetesResourceUtils.fetchResourceList({
clusterIdentifier: ci,
metricName: "k8s.daemonset.desired_scheduled_nodes",
resourceNameAttribute: "resource.k8s.daemonset.name",
}),
KubernetesResourceUtils.fetchResourceList({
clusterIdentifier: ci,
metricName: "k8s.job.active_pods",
resourceNameAttribute: "resource.k8s.job.name",
}),
KubernetesResourceUtils.fetchResourceList({
clusterIdentifier: ci,
metricName: "k8s.cronjob.active_jobs",
resourceNameAttribute: "resource.k8s.cronjob.name",
}),
KubernetesResourceUtils.fetchResourceList({
clusterIdentifier: ci,
metricName: "container.cpu.utilization",
resourceNameAttribute: "resource.k8s.container.name",
}),
]);
setResourceCounts({
nodes: nodes.length,
pods: pods.length,
namespaces: namespaces.length,
deployments: deployments.length,
statefulSets: statefulSets.length,
daemonSets: daemonSets.length,
jobs: jobs.length,
cronJobs: cronJobs.length,
containers: containers.length,
});
} catch {
// Counts are supplementary, don't fail the layout
}
};
fetchCounts().catch(() => {});
}, []);
return (
<ModelPage
title="Kubernetes Cluster"
@@ -22,7 +131,9 @@ const KubernetesClusterViewLayout: FunctionComponent<
modelId={modelId}
modelNameField="name"
breadcrumbLinks={getKubernetesBreadcrumbs(path)}
sideMenu={<SideMenu modelId={modelId} />}
sideMenu={
<SideMenu modelId={modelId} resourceCounts={resourceCounts} />
}
>
<Outlet />
</ModelPage>

View File

@@ -28,6 +28,7 @@ import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetri
import { KubernetesNamespaceObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils";
import KubernetesYamlTab from "../../../Components/Kubernetes/KubernetesYamlTab";
const KubernetesClusterNamespaceDetail: FunctionComponent<
PageComponentProps
@@ -224,6 +225,16 @@ const KubernetesClusterNamespaceDetail: FunctionComponent<
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="namespaces"
resourceName={namespaceName}
/>
),
},
];
return <Tabs tabs={tabs} onTabChange={() => {}} />;

View File

@@ -28,6 +28,7 @@ import {
} from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils";
import KubernetesYamlTab from "../../../Components/Kubernetes/KubernetesYamlTab";
const KubernetesClusterNodeDetail: FunctionComponent<
PageComponentProps
@@ -257,6 +258,39 @@ const KubernetesClusterNodeDetail: FunctionComponent<
if (nodeObject) {
const nodeStatus: { label: string; isReady: boolean } = getNodeStatus();
// Extract node roles from labels
const roles: Array<string> = Object.keys(
nodeObject.metadata.labels,
)
.filter((key: string) => {
return key.startsWith("node-role.kubernetes.io/");
})
.map((key: string) => {
return key.replace("node-role.kubernetes.io/", "");
});
// Extract internal IP
const internalIP: string =
nodeObject.status.addresses.find(
(a: { type: string; address: string }) => {
return a.type === "InternalIP";
},
)?.address || "N/A";
// Check pressure conditions
const pressureConditions: Array<string> = nodeObject.status.conditions
.filter((c: KubernetesCondition) => {
return (
c.status === "True" &&
(c.type === "MemoryPressure" ||
c.type === "DiskPressure" ||
c.type === "PIDPressure")
);
})
.map((c: KubernetesCondition) => {
return c.type;
});
summaryFields.push(
{
title: "Status",
@@ -272,14 +306,69 @@ const KubernetesClusterNodeDetail: FunctionComponent<
</span>
),
},
);
if (roles.length > 0) {
summaryFields.push({
title: "Roles",
value: (
<div className="flex gap-1 flex-wrap">
{roles.map((role: string) => {
return (
<span
key={role}
className="inline-flex px-2 py-0.5 text-xs rounded bg-indigo-50 text-indigo-700"
>
{role}
</span>
);
})}
</div>
),
});
}
summaryFields.push(
{ title: "Internal IP", value: internalIP },
);
if (pressureConditions.length > 0) {
summaryFields.push({
title: "Pressure",
value: (
<div className="flex gap-1 flex-wrap">
{pressureConditions.map((p: string) => {
return (
<span
key={p}
className="inline-flex px-2 py-0.5 text-xs rounded-full bg-red-100 text-red-800 font-medium"
>
{p}
</span>
);
})}
</div>
),
});
}
summaryFields.push(
{
title: "CPU (Capacity / Allocatable)",
value: `${nodeObject.status.capacity["cpu"] || "N/A"} / ${nodeObject.status.allocatable["cpu"] || "N/A"}`,
},
{
title: "Memory (Capacity / Allocatable)",
value: `${nodeObject.status.capacity["memory"] || "N/A"} / ${nodeObject.status.allocatable["memory"] || "N/A"}`,
},
{
title: "Pods (Capacity)",
value: nodeObject.status.capacity["pods"] || "N/A",
},
{
title: "OS Image",
value: nodeObject.status.nodeInfo.osImage || "N/A",
},
{
title: "Kernel",
value: nodeObject.status.nodeInfo.kernelVersion || "N/A",
},
{
title: "Container Runtime",
value: nodeObject.status.nodeInfo.containerRuntimeVersion || "N/A",
@@ -290,19 +379,19 @@ const KubernetesClusterNodeDetail: FunctionComponent<
},
{
title: "Architecture",
value: nodeObject.status.nodeInfo.architecture || "N/A",
value: `${nodeObject.status.nodeInfo.operatingSystem || "N/A"}/${nodeObject.status.nodeInfo.architecture || "N/A"}`,
},
{
title: "CPU Allocatable",
value: nodeObject.status.allocatable["cpu"] || "N/A",
},
{
title: "Memory Allocatable",
value: nodeObject.status.allocatable["memory"] || "N/A",
title: "Kernel",
value: nodeObject.status.nodeInfo.kernelVersion || "N/A",
},
{
title: "Created",
value: nodeObject.metadata.creationTimestamp || "N/A",
value: nodeObject.metadata.creationTimestamp
? KubernetesResourceUtils.formatAge(
nodeObject.metadata.creationTimestamp,
)
: "N/A",
},
);
}
@@ -354,6 +443,16 @@ const KubernetesClusterNodeDetail: FunctionComponent<
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="nodes"
resourceName={nodeName}
/>
),
},
];
return <Tabs tabs={tabs} onTabChange={() => {}} />;

View File

@@ -20,6 +20,7 @@ import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOver
import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab";
import { KubernetesPVCObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
import KubernetesYamlTab from "../../../Components/Kubernetes/KubernetesYamlTab";
const KubernetesClusterPVCDetail: FunctionComponent<
PageComponentProps
@@ -179,6 +180,17 @@ const KubernetesClusterPVCDetail: FunctionComponent<
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="persistentvolumeclaims"
resourceName={pvcName}
namespace={pvcObject?.metadata.namespace}
/>
),
},
];
return <Tabs tabs={tabs} onTabChange={() => {}} />;

View File

@@ -20,6 +20,7 @@ import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOver
import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab";
import { KubernetesPVObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
import KubernetesYamlTab from "../../../Components/Kubernetes/KubernetesYamlTab";
const KubernetesClusterPVDetail: FunctionComponent<
PageComponentProps
@@ -177,6 +178,16 @@ const KubernetesClusterPVDetail: FunctionComponent<
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="persistentvolumes"
resourceName={pvName}
/>
),
},
];
return <Tabs tabs={tabs} onTabChange={() => {}} />;

View File

@@ -29,6 +29,7 @@ import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetri
import { KubernetesPodObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils";
import KubernetesYamlTab from "../../../Components/Kubernetes/KubernetesYamlTab";
const KubernetesClusterPodDetail: FunctionComponent<
PageComponentProps
@@ -223,6 +224,21 @@ const KubernetesClusterPodDetail: FunctionComponent<
];
if (podObject) {
// Compute restart count
const restartCount: number = podObject.status.containerStatuses.reduce(
(sum: number, cs: { restartCount: number }) => {
return sum + cs.restartCount;
},
0,
);
// Compute container images
const containerImages: Array<string> = podObject.spec.containers.map(
(c: { image: string }) => {
return c.image;
},
);
summaryFields.push(
{
title: "Namespace",
@@ -246,6 +262,24 @@ const KubernetesClusterPodDetail: FunctionComponent<
</span>
),
},
{
title: "QoS Class",
value: podObject.status.qosClass || "N/A",
},
{
title: "Restarts",
value: (
<span
className={
restartCount > 0
? "text-yellow-700 font-medium"
: "text-gray-700"
}
>
{restartCount.toString()}
</span>
),
},
{ title: "Node", value: podObject.spec.nodeName || "N/A" },
{ title: "Pod IP", value: podObject.status.podIP || "N/A" },
{ title: "Host IP", value: podObject.status.hostIP || "N/A" },
@@ -253,9 +287,30 @@ const KubernetesClusterPodDetail: FunctionComponent<
title: "Service Account",
value: podObject.spec.serviceAccountName || "default",
},
{
title: "Images",
value: (
<div className="space-y-1">
{containerImages.map((img: string, idx: number) => {
return (
<div
key={idx}
className="text-xs font-mono bg-gray-50 px-2 py-1 rounded"
>
{img}
</div>
);
})}
</div>
),
},
{
title: "Created",
value: podObject.metadata.creationTimestamp || "N/A",
value: podObject.metadata.creationTimestamp
? KubernetesResourceUtils.formatAge(
podObject.metadata.creationTimestamp,
)
: "N/A",
},
);
}
@@ -331,6 +386,17 @@ const KubernetesClusterPodDetail: FunctionComponent<
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="pods"
resourceName={podName}
namespace={podObject?.metadata.namespace}
/>
),
},
];
return <Tabs tabs={tabs} onTabChange={() => {}} />;

View File

@@ -8,13 +8,30 @@ import SideMenuItem from "Common/UI/Components/SideMenu/SideMenuItem";
import SideMenuSection from "Common/UI/Components/SideMenu/SideMenuSection";
import React, { FunctionComponent, ReactElement } from "react";
export interface ResourceCounts {
namespaces?: number | undefined;
pods?: number | undefined;
deployments?: number | undefined;
statefulSets?: number | undefined;
daemonSets?: number | undefined;
jobs?: number | undefined;
cronJobs?: number | undefined;
nodes?: number | undefined;
containers?: number | undefined;
pvcs?: number | undefined;
pvs?: number | undefined;
}
export interface ComponentProps {
modelId: ObjectID;
resourceCounts?: ResourceCounts | undefined;
}
const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const counts: ResourceCounts = props.resourceCounts || {};
return (
<SideMenu>
<SideMenuSection title="Basic">
@@ -50,6 +67,7 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Folder}
badge={counts.namespaces}
/>
<SideMenuItem
link={{
@@ -60,6 +78,7 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Circle}
badge={counts.pods}
/>
<SideMenuItem
link={{
@@ -70,6 +89,7 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Layers}
badge={counts.deployments}
/>
<SideMenuItem
link={{
@@ -80,6 +100,7 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Database}
badge={counts.statefulSets}
/>
<SideMenuItem
link={{
@@ -90,6 +111,7 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Settings}
badge={counts.daemonSets}
/>
<SideMenuItem
link={{
@@ -100,6 +122,7 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Play}
badge={counts.jobs}
/>
<SideMenuItem
link={{
@@ -110,6 +133,7 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Clock}
badge={counts.cronJobs}
/>
</SideMenuSection>
@@ -123,6 +147,7 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Server}
badge={counts.nodes}
/>
<SideMenuItem
link={{
@@ -133,6 +158,7 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Cube}
badge={counts.containers}
/>
<SideMenuItem
link={{
@@ -145,6 +171,7 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Disc}
badge={counts.pvcs}
/>
<SideMenuItem
link={{
@@ -157,6 +184,7 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Disc}
badge={counts.pvs}
/>
</SideMenuSection>

View File

@@ -28,6 +28,7 @@ import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetri
import { KubernetesStatefulSetObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils";
import KubernetesYamlTab from "../../../Components/Kubernetes/KubernetesYamlTab";
const KubernetesClusterStatefulSetDetail: FunctionComponent<
PageComponentProps
@@ -244,6 +245,17 @@ const KubernetesClusterStatefulSetDetail: FunctionComponent<
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="statefulsets"
resourceName={statefulSetName}
namespace={objectData?.metadata.namespace}
/>
),
},
];
return <Tabs tabs={tabs} onTabChange={() => {}} />;

View File

@@ -6,7 +6,7 @@ enum CodeType {
Markdown = "markdown",
SQL = "sql",
Text = "text",
// TODO add more mime types.
YAML = "yaml",
}
export default CodeType;