feat: Refactor Kubernetes Dashboard components with new UI elements

- Added StatusBadge component for consistent status representation across the dashboard.
- Introduced AlertBanner component for displaying cluster health and node pressure alerts.
- Implemented FilterButtons component for improved event filtering options.
- Created ResourceUsageBar for visualizing resource usage in pods.
- Developed StackedProgressBar for displaying pod health breakdown.
- Added ConditionsTable for displaying conditions with improved styling and functionality.
- Enhanced existing components (DeploymentDetail, Events, Index, PodDetail) to utilize new UI components and improve overall user experience.
This commit is contained in:
Nawaz Dhandala
2026-03-20 08:20:03 +00:00
parent 4f67228eaf
commit 2ef7988598
13 changed files with 785 additions and 497 deletions

View File

@@ -10,6 +10,11 @@ import {
} from "../../Pages/Kubernetes/Utils/KubernetesObjectFetcher";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import FilterButtons from "Common/UI/Components/FilterButtons/FilterButtons";
import type { FilterButtonOption } from "Common/UI/Components/FilterButtons/FilterButtons";
import StatusBadge, {
StatusBadgeType,
} from "Common/UI/Components/StatusBadge/StatusBadge";
export interface ComponentProps {
clusterIdentifier: string;
@@ -24,9 +29,7 @@ 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",
);
const [typeFilter, setTypeFilter] = useState<string>("all");
useEffect(() => {
const fetchEvents: () => Promise<void> = async (): Promise<void> => {
@@ -90,6 +93,12 @@ const KubernetesEventsTab: FunctionComponent<ComponentProps> = (
},
);
const filterOptions: Array<FilterButtonOption> = [
{ label: "All", value: "all" },
{ label: "Warnings", value: "warning", badge: warningCount },
{ label: "Normal", value: "normal", badge: normalCount },
];
return (
<div>
{/* Summary and Filters */}
@@ -99,40 +108,22 @@ const KubernetesEventsTab: FunctionComponent<ComponentProps> = (
{warningCount > 0 && (
<span>
{" "}
(<span className="text-yellow-700 font-medium">
(<span className="text-amber-700 font-medium">
{warningCount}
</span>{" "}
warning{warningCount !== 1 ? "s" : ""},{" "}
<span className="text-green-700 font-medium">{normalCount}</span>{" "}
<span className="text-emerald-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>
<FilterButtons
options={filterOptions}
selectedValue={typeFilter}
onSelect={setTypeFilter}
/>
</div>
<div className="overflow-x-auto">
@@ -161,21 +152,20 @@ const KubernetesEventsTab: FunctionComponent<ComponentProps> = (
return (
<tr
key={index}
className={isWarning ? "bg-yellow-50" : ""}
className={isWarning ? "bg-amber-50/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 ${
<StatusBadge
text={event.type}
type={
isWarning
? "bg-yellow-100 text-yellow-800"
: "bg-green-100 text-green-800"
}`}
>
{event.type}
</span>
? StatusBadgeType.Warning
: StatusBadgeType.Success
}
/>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{event.reason}

View File

@@ -1,71 +1,11 @@
import React, { FunctionComponent, ReactElement, useState } from "react";
import React, { FunctionComponent, ReactElement } 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`;
}
import ConditionsTable from "Common/UI/Components/ConditionsTable/ConditionsTable";
import type { Condition } from "Common/UI/Components/ConditionsTable/ConditionsTable";
export interface SummaryField {
title: string;
@@ -82,37 +22,6 @@ 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 => {
@@ -132,6 +41,17 @@ const KubernetesOverviewTab: FunctionComponent<ComponentProps> = (
);
}
// Convert KubernetesCondition[] to generic Condition[] for ConditionsTable
const conditions: Array<Condition> | undefined = props.conditions?.map(
(c: KubernetesCondition): Condition => ({
type: c.type,
status: c.status,
reason: c.reason,
message: c.message,
lastTransitionTime: c.lastTransitionTime,
}),
);
return (
<div className="space-y-6">
{/* Summary Info Cards */}
@@ -169,75 +89,12 @@ const KubernetesOverviewTab: FunctionComponent<ComponentProps> = (
)}
{/* Conditions */}
{props.conditions && props.conditions.length > 0 && (
{conditions && conditions.length > 0 && (
<Card
title="Conditions"
description="Current status conditions of this resource."
>
<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">
Type
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</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>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Last Transition
</th>
</tr>
</thead>
<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}
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-semibold rounded-full ${getConditionStatusColor(condition)}`}
>
{condition.status}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
{condition.reason || "-"}
</td>
<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">
<span
title={condition.lastTransitionTime || ""}
>
{formatRelativeTime(
condition.lastTransitionTime,
)}
</span>
</td>
</tr>
);
},
)}
</tbody>
</table>
</div>
<ConditionsTable conditions={conditions} />
</Card>
)}

View File

@@ -29,6 +29,9 @@ import { KubernetesDeploymentObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils";
import KubernetesYamlTab from "../../../Components/Kubernetes/KubernetesYamlTab";
import StatusBadge, {
StatusBadgeType,
} from "Common/UI/Components/StatusBadge/StatusBadge";
const KubernetesClusterDeploymentDetail: FunctionComponent<
PageComponentProps
@@ -191,22 +194,21 @@ const KubernetesClusterDeploymentDetail: FunctionComponent<
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 ${
<StatusBadge
text={isFullyRolledOut ? "Complete" : "In Progress"}
type={
isFullyRolledOut
? "bg-green-100 text-green-800"
: "bg-yellow-100 text-yellow-800"
}`}
>
{isFullyRolledOut ? "Complete" : "In Progress"}
</span>
? StatusBadgeType.Success
: StatusBadgeType.Warning
}
/>
<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="w-32 bg-gray-100 rounded-full h-2">
<div
className={`h-2 rounded-full ${isFullyRolledOut ? "bg-green-500" : "bg-yellow-500"}`}
className={`h-2 rounded-full transition-all duration-300 ${isFullyRolledOut ? "bg-emerald-500" : "bg-amber-500"}`}
style={{
width: `${desired > 0 ? (ready / desired) * 100 : 0}%`,
}}

View File

@@ -26,6 +26,11 @@ import { JSONObject } from "Common/Types/JSON";
import InBetween from "Common/Types/BaseDatabase/InBetween";
import { getKvValue, getKvStringValue } from "../Utils/KubernetesObjectParser";
import { KubernetesEvent } from "../Utils/KubernetesObjectFetcher";
import FilterButtons from "Common/UI/Components/FilterButtons/FilterButtons";
import type { FilterButtonOption } from "Common/UI/Components/FilterButtons/FilterButtons";
import StatusBadge, {
StatusBadgeType,
} from "Common/UI/Components/StatusBadge/StatusBadge";
const KubernetesClusterEvents: FunctionComponent<
PageComponentProps
@@ -36,9 +41,7 @@ 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 [typeFilter, setTypeFilter] = useState<string>("all");
const [namespaceFilter, setNamespaceFilter] = useState<string>("all");
const [searchText, setSearchText] = useState<string>("");
@@ -247,6 +250,12 @@ const KubernetesClusterEvents: FunctionComponent<
},
);
const filterOptions: Array<FilterButtonOption> = [
{ label: "All Types", value: "all" },
{ label: "Warnings", value: "warning", badge: warningCount },
{ label: "Normal", value: "normal", badge: normalCount },
];
return (
<Fragment>
<Card
@@ -262,43 +271,24 @@ const KubernetesClusterEvents: FunctionComponent<
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>
<StatusBadge
text={`${warningCount} Warning${warningCount !== 1 ? "s" : ""}`}
type={StatusBadgeType.Warning}
/>
)}
<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>
<StatusBadge
text={`${normalCount} Normal`}
type={StatusBadgeType.Success}
/>
</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>
<FilterButtons
options={filterOptions}
selectedValue={typeFilter}
onSelect={setTypeFilter}
/>
{/* Namespace Filter */}
<select
@@ -377,21 +367,20 @@ const KubernetesClusterEvents: FunctionComponent<
return (
<tr
key={index}
className={isWarning ? "bg-yellow-50" : ""}
className={isWarning ? "bg-amber-50/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 ${
<StatusBadge
text={event.type}
type={
isWarning
? "bg-yellow-100 text-yellow-800"
: "bg-green-100 text-green-800"
}`}
>
{event.type}
</span>
? StatusBadgeType.Warning
: StatusBadgeType.Success
}
/>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{event.reason}
@@ -400,9 +389,10 @@ const KubernetesClusterEvents: FunctionComponent<
{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>
<StatusBadge
text={event.namespace}
type={StatusBadgeType.Info}
/>
</td>
<td className="px-4 py-3 text-sm text-gray-500 max-w-md">
{event.message}

View File

@@ -35,7 +35,15 @@ import {
KubernetesPodObject,
KubernetesNodeObject,
} from "../Utils/KubernetesObjectParser";
import OneUptimeDate from "Common/Types/Date";
import AlertBanner, {
AlertBannerType,
} from "Common/UI/Components/AlertBanner/AlertBanner";
import StackedProgressBar from "Common/UI/Components/StackedProgressBar/StackedProgressBar";
import type { StackedProgressBarSegment } from "Common/UI/Components/StackedProgressBar/StackedProgressBar";
import StatusBadge, {
StatusBadgeType,
} from "Common/UI/Components/StatusBadge/StatusBadge";
import ResourceUsageBar from "Common/UI/Components/ResourceUsageBar/ResourceUsageBar";
interface ResourceLink {
title: string;
@@ -287,10 +295,12 @@ const KubernetesClusterOverview: FunctionComponent<
return <ErrorMessage message="Cluster not found." />;
}
const statusColor: string =
cluster.otelCollectorStatus === "connected"
? "text-green-600"
: "text-red-600";
const healthBannerType: AlertBannerType =
clusterHealth === "Healthy"
? AlertBannerType.Success
: clusterHealth === "Degraded"
? AlertBannerType.Warning
: AlertBannerType.Danger;
const workloadLinks: Array<ResourceLink> = [
{
@@ -353,51 +363,103 @@ const KubernetesClusterOverview: FunctionComponent<
},
];
// Build pod health segments for StackedProgressBar
const podHealthSegments: Array<StackedProgressBarSegment> = [
{
value: podHealthSummary.running,
color: "bg-emerald-500",
label: "Running",
},
{
value: podHealthSummary.succeeded,
color: "bg-blue-500",
label: "Succeeded",
},
{
value: podHealthSummary.pending,
color: "bg-amber-500",
label: "Pending",
},
{
value: podHealthSummary.failed,
color: "bg-red-500",
label: "Failed",
},
];
// Build pressure badges
const pressureBadges: Array<{ count: number; label: string }> = [];
if (nodePressure.memoryPressure > 0) {
pressureBadges.push({
count: nodePressure.memoryPressure,
label: "Memory Pressure",
});
}
if (nodePressure.diskPressure > 0) {
pressureBadges.push({
count: nodePressure.diskPressure,
label: "Disk Pressure",
});
}
if (nodePressure.pidPressure > 0) {
pressureBadges.push({
count: nodePressure.pidPressure,
label: "PID Pressure",
});
}
const renderResourceLinks: (links: Array<ResourceLink>) => ReactElement = (
links: Array<ResourceLink>,
): ReactElement => {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 p-4">
{links.map((link: ResourceLink) => {
return (
<div
key={link.title}
onClick={() => {
Navigation.navigate(
RouteUtil.populateRouteParams(
RouteMap[link.pageMap] as Route,
{ modelId: modelId },
),
);
}}
className="flex items-center p-3 rounded-lg border border-gray-200 hover:border-indigo-300 hover:bg-indigo-50/50 transition-all duration-150 group cursor-pointer"
>
<div>
<div className="font-medium text-gray-900 group-hover:text-indigo-700">
{link.title}
</div>
<div className="text-xs text-gray-500">
{link.description}
</div>
</div>
</div>
);
})}
</div>
);
};
return (
<Fragment>
{/* Cluster Health Banner */}
<div
className={`mb-5 rounded-lg border p-4 ${
clusterHealth === "Healthy"
? "bg-green-50 border-green-200"
: clusterHealth === "Degraded"
? "bg-yellow-50 border-yellow-200"
: "bg-red-50 border-red-200"
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span
className={`inline-flex h-3 w-3 rounded-full ${
clusterHealth === "Healthy"
? "bg-green-500"
: clusterHealth === "Degraded"
? "bg-yellow-500"
: "bg-red-500"
}`}
/>
<span
className={`text-lg font-semibold ${
clusterHealth === "Healthy"
? "text-green-800"
: clusterHealth === "Degraded"
? "text-yellow-800"
: "text-red-800"
}`}
>
Cluster {clusterHealth}
</span>
</div>
<AlertBanner
title={`Cluster ${clusterHealth}`}
type={healthBannerType}
className="mb-5"
rightElement={
<div className="flex gap-4 text-sm">
<span className="text-gray-600">
<span className="font-medium text-green-700">
<span className="font-medium text-emerald-700">
{podHealthSummary.running}
</span>{" "}
Running
</span>
{podHealthSummary.pending > 0 && (
<span className="text-gray-600">
<span className="font-medium text-yellow-700">
<span className="font-medium text-amber-700">
{podHealthSummary.pending}
</span>{" "}
Pending
@@ -420,8 +482,8 @@ const KubernetesClusterOverview: FunctionComponent<
</span>
)}
</div>
</div>
</div>
}
/>
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mb-5">
@@ -431,9 +493,9 @@ const KubernetesClusterOverview: FunctionComponent<
<span
className={`text-2xl font-semibold ${
clusterHealth === "Healthy"
? "text-green-600"
? "text-emerald-600"
: clusterHealth === "Degraded"
? "text-yellow-600"
? "text-amber-600"
: "text-red-600"
}`}
>
@@ -473,11 +535,18 @@ const KubernetesClusterOverview: FunctionComponent<
<InfoCard
title="Agent Status"
value={
<span className={`text-2xl font-semibold ${statusColor}`}>
{cluster.otelCollectorStatus === "connected"
? "Connected"
: "Disconnected"}
</span>
<StatusBadge
text={
cluster.otelCollectorStatus === "connected"
? "Connected"
: "Disconnected"
}
type={
cluster.otelCollectorStatus === "connected"
? StatusBadgeType.Success
: StatusBadgeType.Danger
}
/>
}
/>
</div>
@@ -487,33 +556,7 @@ const KubernetesClusterOverview: FunctionComponent<
title="Workloads"
description="Explore workload resources in this cluster."
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 p-4">
{workloadLinks.map((link: ResourceLink) => {
return (
<div
key={link.title}
onClick={() => {
Navigation.navigate(
RouteUtil.populateRouteParams(
RouteMap[link.pageMap] as Route,
{ modelId: modelId },
),
);
}}
className="flex items-center p-3 rounded-lg border border-gray-200 hover:border-indigo-300 hover:bg-indigo-50 transition-colors group cursor-pointer"
>
<div>
<div className="font-medium text-gray-900 group-hover:text-indigo-700">
{link.title}
</div>
<div className="text-xs text-gray-500">
{link.description}
</div>
</div>
</div>
);
})}
</div>
{renderResourceLinks(workloadLinks)}
</Card>
{/* Quick Navigation - Infrastructure */}
@@ -521,67 +564,30 @@ const KubernetesClusterOverview: FunctionComponent<
title="Infrastructure"
description="Explore infrastructure resources in this cluster."
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 p-4">
{infraLinks.map((link: ResourceLink) => {
return (
<div
key={link.title}
onClick={() => {
Navigation.navigate(
RouteUtil.populateRouteParams(
RouteMap[link.pageMap] as Route,
{ modelId: modelId },
),
);
}}
className="flex items-center p-3 rounded-lg border border-gray-200 hover:border-indigo-300 hover:bg-indigo-50 transition-colors group cursor-pointer"
>
<div>
<div className="font-medium text-gray-900 group-hover:text-indigo-700">
{link.title}
</div>
<div className="text-xs text-gray-500">
{link.description}
</div>
</div>
</div>
);
})}
</div>
{renderResourceLinks(infraLinks)}
</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>
{pressureBadges.length > 0 && (
<AlertBanner
title="Node Pressure Detected"
type={AlertBannerType.Danger}
className="mb-5"
>
<div className="flex gap-3 mt-1">
{pressureBadges.map(
(badge: { count: number; label: string }) => {
return (
<StatusBadge
key={badge.label}
text={`${badge.count} node${badge.count > 1 ? "s" : ""}: ${badge.label}`}
type={StatusBadgeType.Danger}
/>
);
},
)}
</div>
</div>
</AlertBanner>
)}
{/* Pod Health Visual Breakdown */}
@@ -591,76 +597,10 @@ const KubernetesClusterOverview: FunctionComponent<
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>
<StackedProgressBar
segments={podHealthSegments}
totalValue={podCount}
/>
</div>
</Card>
)}
@@ -680,30 +620,16 @@ const KubernetesClusterOverview: FunctionComponent<
<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>
<ResourceUsageBar
key={index}
label={pod.name}
value={Math.min(pod.cpuUtilization ?? 0, 100)}
valueLabel={KubernetesResourceUtils.formatCpuValue(
pod.cpuUtilization,
)}
secondaryLabel={pod.namespace}
/>
);
},
)}
@@ -722,11 +648,12 @@ const KubernetesClusterOverview: FunctionComponent<
<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>
<StatusBadge
text={pod.namespace}
type={StatusBadgeType.Info}
/>
<div className="flex-1">
<span className="text-xs text-gray-600">
<span className="text-xs text-gray-600 font-medium">
{KubernetesResourceUtils.formatMemoryValue(
pod.memoryUsageBytes,
)}
@@ -755,11 +682,13 @@ const KubernetesClusterOverview: FunctionComponent<
return (
<div
key={index}
className="flex items-start gap-3 p-3 rounded-lg bg-yellow-50 border border-yellow-100"
className="flex items-start gap-3 p-3 rounded-lg bg-amber-50/50 border border-amber-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>
<StatusBadge
text={event.reason}
type={StatusBadgeType.Warning}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="text-sm text-gray-800">
{event.message}

View File

@@ -30,6 +30,9 @@ import { KubernetesPodObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils";
import KubernetesYamlTab from "../../../Components/Kubernetes/KubernetesYamlTab";
import StatusBadge, {
StatusBadgeType,
} from "Common/UI/Components/StatusBadge/StatusBadge";
const KubernetesClusterPodDetail: FunctionComponent<
PageComponentProps
@@ -247,19 +250,18 @@ const KubernetesClusterPodDetail: FunctionComponent<
{
title: "Status",
value: (
<span
className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${
<StatusBadge
text={podObject.status.phase || "Unknown"}
type={
podObject.status.phase === "Running"
? "bg-green-50 text-green-700"
? StatusBadgeType.Success
: podObject.status.phase === "Succeeded"
? "bg-blue-50 text-blue-700"
? StatusBadgeType.Info
: podObject.status.phase === "Failed"
? "bg-red-50 text-red-700"
: "bg-yellow-50 text-yellow-700"
}`}
>
{podObject.status.phase || "Unknown"}
</span>
? StatusBadgeType.Danger
: StatusBadgeType.Warning
}
/>
),
},
{

View File

@@ -0,0 +1,71 @@
import React, { FunctionComponent, ReactElement } from "react";
export enum AlertBannerType {
Success = "success",
Warning = "warning",
Danger = "danger",
Info = "info",
}
export interface ComponentProps {
title: string;
type: AlertBannerType;
children?: ReactElement | undefined;
rightElement?: ReactElement | undefined;
className?: string | undefined;
}
const bannerStyles: Record<
AlertBannerType,
{ container: string; dot: string; title: string }
> = {
[AlertBannerType.Success]: {
container: "bg-emerald-50 border-emerald-200",
dot: "bg-emerald-500",
title: "text-emerald-800",
},
[AlertBannerType.Warning]: {
container: "bg-amber-50 border-amber-200",
dot: "bg-amber-500",
title: "text-amber-800",
},
[AlertBannerType.Danger]: {
container: "bg-red-50 border-red-200",
dot: "bg-red-500",
title: "text-red-800",
},
[AlertBannerType.Info]: {
container: "bg-blue-50 border-blue-200",
dot: "bg-blue-500",
title: "text-blue-800",
},
};
const AlertBanner: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const styles: { container: string; dot: string; title: string } =
bannerStyles[props.type];
return (
<div
className={`rounded-lg border p-4 ${styles.container} ${props.className || ""}`}
role="alert"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span
className={`inline-flex h-3 w-3 rounded-full ${styles.dot}`}
/>
<span className={`text-lg font-semibold ${styles.title}`}>
{props.title}
</span>
</div>
{props.rightElement && <div>{props.rightElement}</div>}
</div>
{props.children && <div className="mt-2">{props.children}</div>}
</div>
);
};
export default AlertBanner;

View File

@@ -0,0 +1,151 @@
import React, { FunctionComponent, ReactElement } from "react";
import ExpandableText from "../ExpandableText/ExpandableText";
export interface Condition {
type: string;
status: string;
reason?: string | undefined;
message?: string | undefined;
lastTransitionTime?: string | undefined;
}
export interface ComponentProps {
conditions: Array<Condition>;
negativeTypes?: Array<string> | undefined;
className?: string | undefined;
}
// Default condition types where "True" is bad
const defaultNegativeTypes: Array<string> = [
"MemoryPressure",
"DiskPressure",
"PIDPressure",
"NetworkUnavailable",
];
function isConditionBad(
condition: Condition,
negativeTypes: Array<string>,
): boolean {
if (negativeTypes.includes(condition.type)) {
return condition.status === "True";
}
return condition.status === "False";
}
function getStatusStyle(
condition: Condition,
negativeTypes: Array<string>,
): string {
const isNegativeType: boolean = negativeTypes.includes(condition.type);
if (condition.status === "True") {
return isNegativeType
? "bg-gradient-to-r from-red-50 to-red-100 text-red-800 ring-1 ring-inset ring-red-200/80"
: "bg-gradient-to-r from-emerald-50 to-emerald-100 text-emerald-800 ring-1 ring-inset ring-emerald-200/80";
}
if (condition.status === "False") {
return isNegativeType
? "bg-gradient-to-r from-emerald-50 to-emerald-100 text-emerald-800 ring-1 ring-inset ring-emerald-200/80"
: "bg-gradient-to-r from-red-50 to-red-100 text-red-800 ring-1 ring-inset ring-red-200/80";
}
return "bg-gradient-to-r from-amber-50 to-amber-100 text-amber-800 ring-1 ring-inset ring-amber-200/80";
}
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`;
}
const ConditionsTable: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const negativeTypes: Array<string> =
props.negativeTypes || defaultNegativeTypes;
if (props.conditions.length === 0) {
return (
<div className="text-gray-500 text-sm p-4">
No conditions available.
</div>
);
}
return (
<div className={`overflow-x-auto ${props.className || ""}`}>
<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">
Type
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</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>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Last Transition
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{props.conditions.map((condition: Condition, index: number) => {
const isBad: boolean = isConditionBad(condition, negativeTypes);
return (
<tr key={index} className={isBad ? "bg-red-50/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-semibold rounded-full ${getStatusStyle(condition, negativeTypes)}`}
>
{condition.status}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
{condition.reason || "-"}
</td>
<td className="px-4 py-3 text-sm max-w-md">
<ExpandableText text={condition.message || "-"} />
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
<span title={condition.lastTransitionTime || ""}>
{formatRelativeTime(condition.lastTransitionTime || "")}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};
export default ConditionsTable;

View File

@@ -0,0 +1,42 @@
import React, { FunctionComponent, ReactElement, useState } from "react";
export interface ComponentProps {
text: string;
maxLength?: number | undefined;
className?: string | undefined;
}
const ExpandableText: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [isExpanded, setIsExpanded] = useState<boolean>(false);
const maxLength: number = props.maxLength || 80;
if (!props.text || props.text === "-") {
return <span className="text-gray-400">-</span>;
}
const isLong: boolean = props.text.length > maxLength;
if (!isLong) {
return (
<span className={props.className || "text-gray-600"}>{props.text}</span>
);
}
return (
<span className={props.className || "text-gray-600"}>
{isExpanded ? props.text : props.text.substring(0, maxLength) + "..."}
<button
onClick={() => {
setIsExpanded(!isExpanded);
}}
className="ml-1.5 text-xs text-indigo-600 hover:text-indigo-800 font-medium"
>
{isExpanded ? "Less" : "More"}
</button>
</span>
);
};
export default ExpandableText;

View File

@@ -0,0 +1,60 @@
import React, { FunctionComponent, ReactElement } from "react";
export interface FilterButtonOption {
label: string;
value: string;
badge?: number | undefined;
}
export interface ComponentProps {
options: Array<FilterButtonOption>;
selectedValue: string;
onSelect: (value: string) => void;
className?: string | undefined;
}
const FilterButtons: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
return (
<div
className={`inline-flex gap-1 ${props.className || ""}`}
role="radiogroup"
aria-label="Filter options"
>
{props.options.map((option: FilterButtonOption) => {
const isActive: boolean = props.selectedValue === option.value;
return (
<button
key={option.value}
onClick={() => {
props.onSelect(option.value);
}}
className={`px-3 py-1.5 text-xs rounded-md font-medium transition-all duration-150 ${
isActive
? "bg-indigo-100 text-indigo-800 ring-1 ring-inset ring-indigo-200"
: "bg-white text-gray-600 ring-1 ring-inset ring-gray-200 hover:bg-gray-50 hover:text-gray-800"
}`}
role="radio"
aria-checked={isActive}
>
{option.label}
{option.badge !== undefined && option.badge > 0 && (
<span
className={`ml-1.5 inline-flex min-w-[1.25rem] justify-center px-1 py-0 text-[10px] rounded-full ${
isActive
? "bg-indigo-200 text-indigo-900"
: "bg-gray-100 text-gray-500"
}`}
>
{option.badge}
</span>
)}
</button>
);
})}
</div>
);
};
export default FilterButtons;

View File

@@ -0,0 +1,58 @@
import React, { FunctionComponent, ReactElement } from "react";
export interface ComponentProps {
label: string;
value: number; // percentage 0-100
valueLabel?: string | undefined;
secondaryLabel?: string | undefined;
heightClassName?: string | undefined;
className?: string | undefined;
labelWidthClassName?: string | undefined;
}
function getBarColor(percent: number): string {
if (percent > 80) {
return "bg-red-500";
}
if (percent > 60) {
return "bg-amber-500";
}
return "bg-emerald-500";
}
const ResourceUsageBar: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const percent: number = Math.min(Math.max(props.value, 0), 100);
const heightClass: string = props.heightClassName || "h-2";
const labelWidthClass: string = props.labelWidthClassName || "w-40";
return (
<div className={`flex items-center gap-3 ${props.className || ""}`}>
<div
className={`${labelWidthClass} truncate text-sm text-gray-800 font-medium`}
title={props.label}
>
{props.label}
</div>
{props.secondaryLabel && (
<span className="inline-flex px-1.5 py-0.5 text-xs rounded bg-blue-50 text-blue-700">
{props.secondaryLabel}
</span>
)}
<div className={`flex-1 bg-gray-100 rounded-full ${heightClass}`}>
<div
className={`${heightClass} rounded-full transition-all duration-300 ${getBarColor(percent)}`}
style={{ width: `${percent}%` }}
/>
</div>
{props.valueLabel && (
<span className="text-xs text-gray-600 w-16 text-right font-medium tabular-nums">
{props.valueLabel}
</span>
)}
</div>
);
};
export default ResourceUsageBar;

View File

@@ -0,0 +1,92 @@
import React, { FunctionComponent, ReactElement } from "react";
export interface StackedProgressBarSegment {
value: number;
color: string; // Tailwind bg class, e.g. "bg-green-500"
label: string;
tooltip?: string | undefined;
}
export interface ComponentProps {
segments: Array<StackedProgressBarSegment>;
totalValue?: number | undefined;
heightClassName?: string | undefined;
showLegend?: boolean | undefined;
className?: string | undefined;
}
const StackedProgressBar: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const total: number =
props.totalValue ||
props.segments.reduce(
(sum: number, seg: StackedProgressBarSegment) => sum + seg.value,
0,
);
const heightClass: string = props.heightClassName || "h-4";
const showLegend: boolean = props.showLegend !== false;
return (
<div className={props.className || ""}>
<div
className={`flex ${heightClass} rounded-full overflow-hidden bg-gray-100`}
role="progressbar"
aria-label="Stacked progress bar"
>
{props.segments.map(
(segment: StackedProgressBarSegment, index: number) => {
if (segment.value <= 0 || total <= 0) {
return null;
}
const widthPercent: number = (segment.value / total) * 100;
return (
<div
key={index}
className={`${segment.color} ${heightClass} transition-all duration-300`}
style={{ width: `${widthPercent}%` }}
title={
segment.tooltip || `${segment.label}: ${segment.value}`
}
/>
);
},
)}
</div>
{showLegend && (
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-2.5">
{props.segments
.filter(
(seg: StackedProgressBarSegment) => seg.value > 0,
)
.map(
(
segment: StackedProgressBarSegment,
index: number,
) => {
return (
<div
key={index}
className="flex items-center gap-1.5"
>
<span
className={`inline-block w-2.5 h-2.5 rounded-full ${segment.color}`}
/>
<span className="text-sm text-gray-600">
{segment.label}{" "}
<span className="font-medium text-gray-800">
({segment.value})
</span>
</span>
</div>
);
},
)}
</div>
)}
</div>
);
};
export default StackedProgressBar;

View File

@@ -0,0 +1,44 @@
import React, { FunctionComponent, ReactElement } from "react";
export enum StatusBadgeType {
Success = "success",
Warning = "warning",
Danger = "danger",
Info = "info",
Neutral = "neutral",
}
export interface ComponentProps {
text: string;
type?: StatusBadgeType | undefined;
className?: string | undefined;
}
const statusStyles: Record<StatusBadgeType, string> = {
[StatusBadgeType.Success]:
"bg-gradient-to-r from-emerald-50 to-emerald-100 text-emerald-800 ring-1 ring-inset ring-emerald-200/80",
[StatusBadgeType.Warning]:
"bg-gradient-to-r from-amber-50 to-amber-100 text-amber-800 ring-1 ring-inset ring-amber-200/80",
[StatusBadgeType.Danger]:
"bg-gradient-to-r from-red-50 to-red-100 text-red-800 ring-1 ring-inset ring-red-200/80",
[StatusBadgeType.Info]:
"bg-gradient-to-r from-blue-50 to-blue-100 text-blue-800 ring-1 ring-inset ring-blue-200/80",
[StatusBadgeType.Neutral]:
"bg-gradient-to-r from-gray-50 to-gray-100 text-gray-700 ring-1 ring-inset ring-gray-200/80",
};
const StatusBadge: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const type: StatusBadgeType = props.type || StatusBadgeType.Neutral;
return (
<span
className={`inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full ${statusStyles[type]} ${props.className || ""}`}
>
{props.text}
</span>
);
};
export default StatusBadge;