mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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={() => {}} />;
|
||||
|
||||
@@ -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={() => {}} />;
|
||||
|
||||
@@ -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={() => {}} />;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} · {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"
|
||||
|
||||
@@ -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={() => {}} />;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={() => {}} />;
|
||||
|
||||
@@ -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={() => {}} />;
|
||||
|
||||
@@ -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={() => {}} />;
|
||||
|
||||
@@ -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={() => {}} />;
|
||||
|
||||
@@ -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={() => {}} />;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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={() => {}} />;
|
||||
|
||||
@@ -6,7 +6,7 @@ enum CodeType {
|
||||
Markdown = "markdown",
|
||||
SQL = "sql",
|
||||
Text = "text",
|
||||
// TODO add more mime types.
|
||||
YAML = "yaml",
|
||||
}
|
||||
|
||||
export default CodeType;
|
||||
|
||||
Reference in New Issue
Block a user