mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}%`,
|
||||
}}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
71
Common/UI/Components/AlertBanner/AlertBanner.tsx
Normal file
71
Common/UI/Components/AlertBanner/AlertBanner.tsx
Normal 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;
|
||||
151
Common/UI/Components/ConditionsTable/ConditionsTable.tsx
Normal file
151
Common/UI/Components/ConditionsTable/ConditionsTable.tsx
Normal 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;
|
||||
42
Common/UI/Components/ExpandableText/ExpandableText.tsx
Normal file
42
Common/UI/Components/ExpandableText/ExpandableText.tsx
Normal 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;
|
||||
60
Common/UI/Components/FilterButtons/FilterButtons.tsx
Normal file
60
Common/UI/Components/FilterButtons/FilterButtons.tsx
Normal 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;
|
||||
58
Common/UI/Components/ResourceUsageBar/ResourceUsageBar.tsx
Normal file
58
Common/UI/Components/ResourceUsageBar/ResourceUsageBar.tsx
Normal 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;
|
||||
@@ -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;
|
||||
44
Common/UI/Components/StatusBadge/StatusBadge.tsx
Normal file
44
Common/UI/Components/StatusBadge/StatusBadge.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user