This commit is contained in:
Nawaz Dhandala
2026-03-20 14:49:34 +00:00
89 changed files with 10566 additions and 1375 deletions

View File

@@ -1630,13 +1630,20 @@ jobs:
PROFILE_PATH=$RUNNER_TEMP/profile.mobileprovision
echo "$IOS_PROVISIONING_PROFILE_BASE64" | base64 --decode > "$PROFILE_PATH"
# Extract the UUID from the profile and install with UUID as filename
# This is required for Xcode to find the profile during manual code signing
PROFILE_UUID=$(/usr/libexec/PlistBuddy -c "Print UUID" /dev/stdin <<< $(/usr/bin/security cms -D -i "$PROFILE_PATH"))
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/
cp "$PROFILE_PATH" ~/Library/MobileDevice/Provisioning\ Profiles/"$PROFILE_UUID".mobileprovision
- name: Build archive
env:
IOS_TEAM_ID: ${{ secrets.IOS_TEAM_ID }}
run: |
if [ -z "$IOS_TEAM_ID" ]; then
echo "::error::IOS_TEAM_ID secret is not set"
exit 1
fi
cd MobileApp
xcodebuild -workspace ios/OneUptimeOnCall.xcworkspace \
-scheme OneUptimeOnCall \
@@ -1644,6 +1651,8 @@ jobs:
-sdk iphoneos \
-archivePath $RUNNER_TEMP/OneUptimeOnCall.xcarchive \
archive \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="iPhone Distribution" \
DEVELOPMENT_TEAM="$IOS_TEAM_ID" \
MARKETING_VERSION=${{ needs.read-version.outputs.major_minor }} \
CURRENT_PROJECT_VERSION=${{ needs.generate-build-number.outputs.build_number }}

View File

@@ -0,0 +1,95 @@
import React, { FunctionComponent, ReactElement } from "react";
import Dropdown, {
DropdownOption,
DropdownOptionGroup,
DropdownValue,
} from "Common/UI/Components/Dropdown/Dropdown";
import {
getAllKubernetesMetrics,
getAllKubernetesMetricCategories,
KubernetesMetricDefinition,
KubernetesMetricCategory,
} from "Common/Types/Monitor/KubernetesMetricCatalog";
export interface ComponentProps {
selectedMetricId?: string | undefined;
onMetricSelected: (metric: KubernetesMetricDefinition) => void;
}
const KubernetesMetricPicker: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const allMetrics: Array<KubernetesMetricDefinition> =
getAllKubernetesMetrics();
const allCategories: Array<KubernetesMetricCategory> =
getAllKubernetesMetricCategories();
const groupedOptions: Array<DropdownOptionGroup> = allCategories.map(
(category: KubernetesMetricCategory) => {
const categoryMetrics: Array<KubernetesMetricDefinition> =
allMetrics.filter(
(m: KubernetesMetricDefinition) => m.category === category,
);
return {
label: category,
options: categoryMetrics.map((m: KubernetesMetricDefinition) => {
return {
label: `${m.friendlyName}${m.unit ? ` (${m.unit})` : ""}`,
value: m.id,
};
}),
};
},
);
const selectedMetric: KubernetesMetricDefinition | undefined =
props.selectedMetricId
? allMetrics.find(
(m: KubernetesMetricDefinition) => m.id === props.selectedMetricId,
)
: undefined;
const selectedOption: DropdownOption | undefined = selectedMetric
? {
label: `${selectedMetric.friendlyName}${selectedMetric.unit ? ` (${selectedMetric.unit})` : ""}`,
value: selectedMetric.id,
}
: undefined;
return (
<div>
<Dropdown
options={groupedOptions}
value={selectedOption}
onChange={(value: DropdownValue | Array<DropdownValue> | null) => {
if (!value) {
return;
}
const metricId: string = value as string;
const metric: KubernetesMetricDefinition | undefined =
allMetrics.find(
(m: KubernetesMetricDefinition) => m.id === metricId,
);
if (metric) {
props.onMetricSelected(metric);
}
}}
placeholder="Select a Kubernetes metric..."
/>
{selectedMetric && (
<p className="mt-2 text-xs text-gray-500">
{selectedMetric.description} Metric:{" "}
<code className="bg-gray-100 px-1 rounded text-xs">
{selectedMetric.metricName}
</code>
</p>
)}
</div>
);
};
export default KubernetesMetricPicker;

View File

@@ -0,0 +1,731 @@
import MonitorStepKubernetesMonitor, {
MonitorStepKubernetesMonitorUtil,
KubernetesResourceScope,
} from "Common/Types/Monitor/MonitorStepKubernetesMonitor";
import MonitorStep from "Common/Types/Monitor/MonitorStep";
import ObjectID from "Common/Types/ObjectID";
import React, { FunctionComponent, ReactElement, useEffect } from "react";
import MetricView from "../../../Metrics/MetricView";
import RollingTime from "Common/Types/RollingTime/RollingTime";
import InBetween from "Common/Types/BaseDatabase/InBetween";
import RollingTimePicker from "Common/UI/Components/RollingTimePicker/RollingTimePicker";
import RollingTimeUtil from "Common/Types/RollingTime/RollingTimeUtil";
import FieldLabelElement from "Common/UI/Components/Forms/Fields/FieldLabel";
import MetricViewData from "Common/Types/Metrics/MetricViewData";
import Dropdown, {
DropdownOption,
DropdownValue,
} from "Common/UI/Components/Dropdown/Dropdown";
import Input from "Common/UI/Components/Input/Input";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import ListResult from "Common/Types/BaseDatabase/ListResult";
import { LIMIT_PER_PROJECT } from "Common/Types/Database/LimitMax";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import Tabs from "Common/UI/Components/Tabs/Tabs";
import { Tab } from "Common/UI/Components/Tabs/Tab";
import MetricsAggregationType from "Common/Types/Metrics/MetricsAggregationType";
import KubernetesTemplatePicker from "./KubernetesTemplatePicker";
import KubernetesMetricPicker from "./KubernetesMetricPicker";
import {
KubernetesAlertTemplate,
getKubernetesAlertTemplateById,
buildKubernetesMonitorConfig,
} from "Common/Types/Monitor/KubernetesAlertTemplates";
import { KubernetesMetricDefinition } from "Common/Types/Monitor/KubernetesMetricCatalog";
import Navigation from "Common/UI/Utils/Navigation";
export type KubernetesFormMode = "quick" | "custom" | "advanced";
export interface ComponentProps {
monitorStepKubernetesMonitor: MonitorStepKubernetesMonitor;
onChange: (
monitorStepKubernetesMonitor: MonitorStepKubernetesMonitor,
) => void;
onMonitorStepOverride?: ((step: MonitorStep) => void) | undefined;
onModeChange?: ((mode: KubernetesFormMode) => void) | undefined;
initialTemplateId?: string | undefined;
initialClusterId?: string | undefined;
}
const resourceScopeOptions: Array<DropdownOption> = [
{
label: "Cluster",
value: KubernetesResourceScope.Cluster,
},
{
label: "Namespace",
value: KubernetesResourceScope.Namespace,
},
{
label: "Workload",
value: KubernetesResourceScope.Workload,
},
{
label: "Node",
value: KubernetesResourceScope.Node,
},
{
label: "Pod",
value: KubernetesResourceScope.Pod,
},
];
const aggregationOptions: Array<DropdownOption> = [
{ label: "Average", value: MetricsAggregationType.Avg },
{ label: "Maximum", value: MetricsAggregationType.Max },
{ label: "Minimum", value: MetricsAggregationType.Min },
{ label: "Sum", value: MetricsAggregationType.Sum },
{ label: "Count", value: MetricsAggregationType.Count },
];
const KubernetesMonitorStepForm: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
// Read query params for template/cluster pre-fill
const urlTemplateId: string | undefined =
props.initialTemplateId ||
Navigation.getQueryStringByName("templateId") ||
undefined;
const urlClusterId: string | undefined =
props.initialClusterId ||
Navigation.getQueryStringByName("clusterId") ||
undefined;
const [_mode, setMode] = React.useState<KubernetesFormMode>("quick");
const [rollingTime, setRollingTime] = React.useState<RollingTime | null>(
null,
);
const monitorStepKubernetesMonitor: MonitorStepKubernetesMonitor =
props.monitorStepKubernetesMonitor ||
MonitorStepKubernetesMonitorUtil.getDefault();
const [startAndEndTime, setStartAndEndTime] =
React.useState<InBetween<Date> | null>(null);
const [clusterOptions, setClusterOptions] = React.useState<
Array<DropdownOption>
>([]);
const [_isLoadingClusters, setIsLoadingClusters] =
React.useState<boolean>(true);
// Quick Setup state
const [selectedTemplateId, setSelectedTemplateId] = React.useState<
string | undefined
>(urlTemplateId);
// Custom Metric state
const [selectedMetricId, setSelectedMetricId] = React.useState<
string | undefined
>(undefined);
const [customAggregation, setCustomAggregation] =
React.useState<MetricsAggregationType>(MetricsAggregationType.Avg);
const [customResourceScope, setCustomResourceScope] =
React.useState<KubernetesResourceScope>(KubernetesResourceScope.Cluster);
useEffect(() => {
// Load clusters
setIsLoadingClusters(true);
ModelAPI.getList<KubernetesCluster>({
modelType: KubernetesCluster,
query: {},
select: {
_id: true,
name: true,
clusterIdentifier: true,
},
sort: {
name: SortOrder.Ascending,
},
limit: LIMIT_PER_PROJECT,
skip: 0,
})
.then((result: ListResult<KubernetesCluster>) => {
const options: Array<DropdownOption> = result.data.map(
(cluster: KubernetesCluster) => {
return {
label: cluster.name || cluster.clusterIdentifier || "Unknown",
value: cluster.clusterIdentifier || "",
};
},
);
setClusterOptions(options);
// Auto-select cluster if initialClusterId or URL param is provided
if (
urlClusterId &&
!monitorStepKubernetesMonitor.clusterIdentifier
) {
const matchedCluster: DropdownOption | undefined = options.find(
(o: DropdownOption) => o.value === urlClusterId,
);
if (matchedCluster) {
props.onChange({
...monitorStepKubernetesMonitor,
clusterIdentifier: matchedCluster.value as string,
});
}
}
})
.catch((_err: Error) => {
setClusterOptions([]);
})
.finally(() => {
setIsLoadingClusters(false);
});
}, []);
// Handle initial template selection
useEffect(() => {
if (urlTemplateId && monitorStepKubernetesMonitor.clusterIdentifier) {
const template: KubernetesAlertTemplate | undefined =
getKubernetesAlertTemplateById(urlTemplateId);
if (template) {
handleTemplateSelection(template);
}
}
}, [props.initialTemplateId, monitorStepKubernetesMonitor.clusterIdentifier]);
useEffect(() => {
if (rollingTime === monitorStepKubernetesMonitor.rollingTime) {
return;
}
setRollingTime(monitorStepKubernetesMonitor.rollingTime);
setStartAndEndTime(
RollingTimeUtil.convertToStartAndEndDate(
monitorStepKubernetesMonitor.rollingTime || RollingTime.Past1Minute,
),
);
}, [monitorStepKubernetesMonitor.rollingTime]);
useEffect(() => {
setStartAndEndTime(
RollingTimeUtil.convertToStartAndEndDate(
monitorStepKubernetesMonitor.rollingTime || RollingTime.Past1Minute,
),
);
}, []);
const handleTemplateSelection = (
template: KubernetesAlertTemplate,
): void => {
setSelectedTemplateId(template.id);
// Build the kubernetes monitor config from the template's getMonitorStep
// We need the cluster identifier to build the config
const clusterIdentifier: string =
monitorStepKubernetesMonitor.clusterIdentifier;
if (!clusterIdentifier) {
// Just store the template selection, config will be built when cluster is selected
return;
}
// Get a dummy monitor step from the template to extract the kubernetes config
const dummyStep: MonitorStep = template.getMonitorStep({
clusterIdentifier,
onlineMonitorStatusId: ObjectID.generate(),
offlineMonitorStatusId: ObjectID.generate(),
defaultIncidentSeverityId: ObjectID.generate(),
defaultAlertSeverityId: ObjectID.generate(),
monitorName: template.name,
});
// Extract the kubernetes monitor config
if (dummyStep.data?.kubernetesMonitor) {
props.onChange({
...dummyStep.data.kubernetesMonitor,
clusterIdentifier,
});
}
};
const handleCustomMetricSelection = (
metric: KubernetesMetricDefinition,
): void => {
setSelectedMetricId(metric.id);
setCustomAggregation(metric.defaultAggregation);
setCustomResourceScope(metric.defaultResourceScope);
const clusterIdentifier: string =
monitorStepKubernetesMonitor.clusterIdentifier;
const config: MonitorStepKubernetesMonitor = buildKubernetesMonitorConfig({
clusterIdentifier: clusterIdentifier || "",
metricName: metric.metricName,
metricAlias: metric.id.replace(/-/g, "_"),
resourceScope: metric.defaultResourceScope,
rollingTime:
monitorStepKubernetesMonitor.rollingTime || RollingTime.Past5Minutes,
aggregationType: metric.defaultAggregation,
});
props.onChange(config);
};
const showNamespaceFilter: boolean =
monitorStepKubernetesMonitor.resourceScope ===
KubernetesResourceScope.Namespace ||
monitorStepKubernetesMonitor.resourceScope ===
KubernetesResourceScope.Workload ||
monitorStepKubernetesMonitor.resourceScope ===
KubernetesResourceScope.Pod;
const showWorkloadFilter: boolean =
monitorStepKubernetesMonitor.resourceScope ===
KubernetesResourceScope.Workload;
const showNodeFilter: boolean =
monitorStepKubernetesMonitor.resourceScope ===
KubernetesResourceScope.Node;
const showPodFilter: boolean =
monitorStepKubernetesMonitor.resourceScope ===
KubernetesResourceScope.Pod;
const renderClusterDropdown = (): ReactElement => {
return (
<div className="mb-4">
<FieldLabelElement
title="Kubernetes Cluster"
description={"Select the Kubernetes cluster to monitor."}
required={true}
/>
<Dropdown
options={clusterOptions}
value={clusterOptions.find(
(option: DropdownOption) =>
option.value === monitorStepKubernetesMonitor.clusterIdentifier,
)}
onChange={(value: DropdownValue | Array<DropdownValue> | null) => {
props.onChange({
...monitorStepKubernetesMonitor,
clusterIdentifier: (value as string) || "",
});
}}
placeholder="Select a cluster..."
/>
</div>
);
};
const renderResourceFilters = (): ReactElement => {
return (
<>
{showNamespaceFilter && (
<div className="mt-3">
<FieldLabelElement
title="Namespace"
description={"Filter by namespace (optional)."}
required={false}
/>
<Input
value={
monitorStepKubernetesMonitor.resourceFilters.namespace || ""
}
onChange={(value: string) => {
props.onChange({
...monitorStepKubernetesMonitor,
resourceFilters: {
...monitorStepKubernetesMonitor.resourceFilters,
namespace: value || undefined,
},
});
}}
placeholder="e.g. default, production"
/>
</div>
)}
{showWorkloadFilter && (
<div className="mt-3">
<FieldLabelElement
title="Workload Name"
description={"Filter by workload name (optional)."}
required={false}
/>
<Input
value={
monitorStepKubernetesMonitor.resourceFilters.workloadName || ""
}
onChange={(value: string) => {
props.onChange({
...monitorStepKubernetesMonitor,
resourceFilters: {
...monitorStepKubernetesMonitor.resourceFilters,
workloadName: value || undefined,
},
});
}}
placeholder="e.g. my-deployment"
/>
</div>
)}
{showNodeFilter && (
<div className="mt-3">
<FieldLabelElement
title="Node Name"
description={"Filter by node name (optional)."}
required={false}
/>
<Input
value={
monitorStepKubernetesMonitor.resourceFilters.nodeName || ""
}
onChange={(value: string) => {
props.onChange({
...monitorStepKubernetesMonitor,
resourceFilters: {
...monitorStepKubernetesMonitor.resourceFilters,
nodeName: value || undefined,
},
});
}}
placeholder="e.g. node-1"
/>
</div>
)}
{showPodFilter && (
<div className="mt-3">
<FieldLabelElement
title="Pod Name"
description={"Filter by pod name (optional)."}
required={false}
/>
<Input
value={
monitorStepKubernetesMonitor.resourceFilters.podName || ""
}
onChange={(value: string) => {
props.onChange({
...monitorStepKubernetesMonitor,
resourceFilters: {
...monitorStepKubernetesMonitor.resourceFilters,
podName: value || undefined,
},
});
}}
placeholder="e.g. my-pod-abc123"
/>
</div>
)}
</>
);
};
const renderQuickSetup = (): ReactElement => {
return (
<div className="mt-4">
<KubernetesTemplatePicker
selectedTemplateId={selectedTemplateId}
onTemplateSelected={(template: KubernetesAlertTemplate) => {
handleTemplateSelection(template);
}}
/>
{selectedTemplateId && (
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-4">
<h4 className="text-sm font-medium text-blue-900 mb-2">
Template Configuration
</h4>
<p className="text-xs text-blue-700 mb-3">
The following settings have been auto-configured. You can adjust
the time range below.
</p>
<FieldLabelElement
title="Time Range"
description={"Adjust the monitoring time range."}
required={true}
/>
<RollingTimePicker
value={monitorStepKubernetesMonitor.rollingTime}
onChange={(value: RollingTime) => {
if (value === monitorStepKubernetesMonitor.rollingTime) {
return;
}
props.onChange({
...monitorStepKubernetesMonitor,
rollingTime: value,
});
}}
/>
</div>
)}
</div>
);
};
const renderCustomMetric = (): ReactElement => {
return (
<div className="mt-4 space-y-4">
<div>
<FieldLabelElement
title="Kubernetes Metric"
description={
"Select a Kubernetes metric to monitor. Metrics are organized by resource type."
}
required={true}
/>
<KubernetesMetricPicker
selectedMetricId={selectedMetricId}
onMetricSelected={(metric: KubernetesMetricDefinition) => {
handleCustomMetricSelection(metric);
}}
/>
</div>
{selectedMetricId && (
<>
<div>
<FieldLabelElement
title="Resource Scope"
description={"Select the scope of resources to monitor."}
required={true}
/>
<Dropdown
options={resourceScopeOptions}
value={resourceScopeOptions.find(
(option: DropdownOption) =>
option.value === customResourceScope,
)}
onChange={(
value: DropdownValue | Array<DropdownValue> | null,
) => {
const newScope: KubernetesResourceScope =
(value as KubernetesResourceScope) ||
KubernetesResourceScope.Cluster;
setCustomResourceScope(newScope);
props.onChange({
...monitorStepKubernetesMonitor,
resourceScope: newScope,
resourceFilters: {},
});
}}
placeholder="Select resource scope..."
/>
</div>
{renderResourceFilters()}
<div>
<FieldLabelElement
title="Aggregation"
description={
"How should the metric values be aggregated over the time range."
}
required={true}
/>
<Dropdown
options={aggregationOptions}
value={aggregationOptions.find(
(option: DropdownOption) =>
option.value === customAggregation,
)}
onChange={(
value: DropdownValue | Array<DropdownValue> | null,
) => {
const newAgg: MetricsAggregationType =
(value as MetricsAggregationType) ||
MetricsAggregationType.Avg;
setCustomAggregation(newAgg);
// Rebuild the config with updated aggregation
if (
monitorStepKubernetesMonitor.metricViewConfig.queryConfigs
.length > 0
) {
const currentQueryConfig =
monitorStepKubernetesMonitor.metricViewConfig
.queryConfigs[0];
if (currentQueryConfig) {
props.onChange({
...monitorStepKubernetesMonitor,
metricViewConfig: {
...monitorStepKubernetesMonitor.metricViewConfig,
queryConfigs: [
{
...currentQueryConfig,
metricQueryData: {
...currentQueryConfig.metricQueryData,
filterData: {
...currentQueryConfig.metricQueryData
.filterData,
aggegationType: newAgg,
},
},
},
],
},
});
}
}
}}
placeholder="Select aggregation..."
/>
</div>
<div>
<FieldLabelElement
title="Time Range"
description={
"Select the time range for the Kubernetes monitor."
}
required={true}
/>
<RollingTimePicker
value={monitorStepKubernetesMonitor.rollingTime}
onChange={(value: RollingTime) => {
if (value === monitorStepKubernetesMonitor.rollingTime) {
return;
}
props.onChange({
...monitorStepKubernetesMonitor,
rollingTime: value,
});
}}
/>
</div>
</>
)}
</div>
);
};
const renderAdvanced = (): ReactElement => {
return (
<div className="mt-4">
<div>
<FieldLabelElement
title="Resource Scope"
description={"Select the scope of resources to monitor."}
required={true}
/>
<Dropdown
options={resourceScopeOptions}
value={resourceScopeOptions.find(
(option: DropdownOption) =>
option.value === monitorStepKubernetesMonitor.resourceScope,
)}
onChange={(value: DropdownValue | Array<DropdownValue> | null) => {
props.onChange({
...monitorStepKubernetesMonitor,
resourceScope:
(value as KubernetesResourceScope) ||
KubernetesResourceScope.Cluster,
resourceFilters: {},
});
}}
placeholder="Select resource scope..."
/>
</div>
{renderResourceFilters()}
<div className="mt-3">
<FieldLabelElement
title="Time Range"
description={"Select the time range for the Kubernetes monitor."}
required={true}
/>
<RollingTimePicker
value={monitorStepKubernetesMonitor.rollingTime}
onChange={(value: RollingTime) => {
if (value === monitorStepKubernetesMonitor.rollingTime) {
return;
}
props.onChange({
...monitorStepKubernetesMonitor,
rollingTime: value,
});
}}
/>
</div>
<div className="mt-3">
<FieldLabelElement
title="Select Metrics"
description={
"Select the Kubernetes metrics to monitor. Use the query builder for full control over metric selection and filtering."
}
required={true}
/>
<div className="mt-3"></div>
<MetricView
hideStartAndEndDate={true}
data={{
startAndEndDate: startAndEndTime,
queryConfigs:
monitorStepKubernetesMonitor.metricViewConfig.queryConfigs,
formulaConfigs:
monitorStepKubernetesMonitor.metricViewConfig.formulaConfigs,
}}
hideCardInQueryElements={true}
hideCardInCharts={true}
chartCssClass="rounded-md border border-gray-200 mt-2 shadow-none"
onChange={(data: MetricViewData) => {
props.onChange({
...monitorStepKubernetesMonitor,
metricViewConfig: {
queryConfigs: data.queryConfigs,
formulaConfigs: data.formulaConfigs,
},
});
}}
/>
</div>
</div>
);
};
const tabs: Array<Tab> = [
{
name: "Quick Setup",
children: renderQuickSetup(),
},
{
name: "Custom Metric",
children: renderCustomMetric(),
},
{
name: "Advanced",
children: renderAdvanced(),
},
];
return (
<div>
{renderClusterDropdown()}
<Tabs
tabs={tabs}
onTabChange={(tab: Tab) => {
let newMode: KubernetesFormMode = "quick";
if (tab.name === "Quick Setup") {
newMode = "quick";
} else if (tab.name === "Custom Metric") {
newMode = "custom";
} else if (tab.name === "Advanced") {
newMode = "advanced";
}
setMode(newMode);
props.onModeChange?.(newMode);
}}
/>
</div>
);
};
export default KubernetesMonitorStepForm;

View File

@@ -0,0 +1,388 @@
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import MonitorCriteria from "Common/Types/Monitor/MonitorCriteria";
import MonitorCriteriaInstance from "Common/Types/Monitor/MonitorCriteriaInstance";
import {
FilterType,
CriteriaFilterUtil,
} from "Common/Types/Monitor/CriteriaFilter";
import {
buildOfflineCriteriaInstance,
buildOnlineCriteriaInstance,
} from "Common/Types/Monitor/KubernetesAlertTemplates";
import ObjectID from "Common/Types/ObjectID";
import Dropdown, {
DropdownOption,
DropdownValue,
} from "Common/UI/Components/Dropdown/Dropdown";
import { InputType } from "Common/UI/Components/Input/Input";
import Input from "Common/UI/Components/Input/Input";
import FieldLabelElement from "Common/UI/Components/Forms/Fields/FieldLabel";
import Toggle from "Common/UI/Components/Toggle/Toggle";
export interface ComponentProps {
metricAlias: string;
monitorName: string;
monitorStatusDropdownOptions: Array<DropdownOption>;
incidentSeverityDropdownOptions: Array<DropdownOption>;
alertSeverityDropdownOptions: Array<DropdownOption>;
onCallPolicyDropdownOptions: Array<DropdownOption>;
value?: MonitorCriteria | undefined;
onChange: (value: MonitorCriteria) => void;
}
const operatorOptions: Array<DropdownOption> = [
{ label: "> Greater Than", value: FilterType.GreaterThan },
{ label: "< Less Than", value: FilterType.LessThan },
{ label: ">= Greater Than or Equal", value: FilterType.GreaterThanOrEqualTo },
{ label: "<= Less Than or Equal", value: FilterType.LessThanOrEqualTo },
{ label: "= Equal To", value: FilterType.EqualTo },
];
function extractStateFromCriteria(criteria: MonitorCriteria | undefined): {
filterType: FilterType;
thresholdValue: number;
alertSeverityId: string;
incidentSeverityId: string;
autoResolve: boolean;
alertOnCallPolicyIds: Array<string>;
incidentOnCallPolicyIds: Array<string>;
} {
const defaults = {
filterType: FilterType.GreaterThan,
thresholdValue: 0,
alertSeverityId: "",
incidentSeverityId: "",
autoResolve: true,
alertOnCallPolicyIds: [] as Array<string>,
incidentOnCallPolicyIds: [] as Array<string>,
};
if (!criteria?.data?.monitorCriteriaInstanceArray?.length) {
return defaults;
}
// Extract from the first criteria instance (the "unhealthy" one)
const firstInstance: MonitorCriteriaInstance | undefined =
criteria.data.monitorCriteriaInstanceArray[0];
if (!firstInstance?.data) {
return defaults;
}
const firstFilter = firstInstance.data.filters?.[0];
if (firstFilter) {
defaults.filterType = firstFilter.filterType || FilterType.GreaterThan;
defaults.thresholdValue =
typeof firstFilter.value === "number"
? firstFilter.value
: parseFloat(String(firstFilter.value)) || 0;
}
if (firstInstance.data.alerts?.length) {
const alert = firstInstance.data.alerts[0];
if (alert) {
defaults.alertSeverityId = alert.alertSeverityId?.toString() || "";
defaults.autoResolve = alert.autoResolveAlert !== false;
defaults.alertOnCallPolicyIds =
alert.onCallPolicyIds?.map((id: ObjectID) => id.toString()) || [];
}
}
if (firstInstance.data.incidents?.length) {
const incident = firstInstance.data.incidents[0];
if (incident) {
defaults.incidentSeverityId =
incident.incidentSeverityId?.toString() || "";
defaults.incidentOnCallPolicyIds =
incident.onCallPolicyIds?.map((id: ObjectID) => id.toString()) || [];
}
}
return defaults;
}
const KubernetesSimplifiedCriteriaForm: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const initialState = extractStateFromCriteria(props.value);
const [filterType, setFilterType] = useState<FilterType>(
initialState.filterType,
);
const [thresholdValue, setThresholdValue] = useState<number>(
initialState.thresholdValue,
);
const [alertSeverityId, setAlertSeverityId] = useState<string>(
initialState.alertSeverityId ||
(props.alertSeverityDropdownOptions[0]?.value?.toString() || ""),
);
const [incidentSeverityId, setIncidentSeverityId] = useState<string>(
initialState.incidentSeverityId ||
(props.incidentSeverityDropdownOptions[0]?.value?.toString() || ""),
);
const [autoResolve, setAutoResolve] = useState<boolean>(
initialState.autoResolve,
);
const [alertOnCallPolicyIds, setAlertOnCallPolicyIds] = useState<
Array<string>
>(initialState.alertOnCallPolicyIds);
const [incidentOnCallPolicyIds, setIncidentOnCallPolicyIds] = useState<
Array<string>
>(initialState.incidentOnCallPolicyIds);
// Find online/offline status IDs from monitor status options
const offlineMonitorStatusId: ObjectID = new ObjectID(
props.monitorStatusDropdownOptions.length >= 2
? (props.monitorStatusDropdownOptions[1]?.value?.toString() || "")
: (props.monitorStatusDropdownOptions[0]?.value?.toString() || ""),
);
const onlineMonitorStatusId: ObjectID = new ObjectID(
props.monitorStatusDropdownOptions[0]?.value?.toString() || "",
);
const buildAndEmitCriteria = (): void => {
if (!props.metricAlias) {
return;
}
const inverseFilterType: FilterType =
CriteriaFilterUtil.getInverseFilterType(filterType);
const offlineInstance: MonitorCriteriaInstance =
buildOfflineCriteriaInstance({
offlineMonitorStatusId,
incidentSeverityId: new ObjectID(incidentSeverityId),
alertSeverityId: new ObjectID(alertSeverityId),
monitorName: props.monitorName || "Kubernetes Monitor",
metricAlias: props.metricAlias,
filterType: filterType,
value: thresholdValue,
});
// Set auto-resolve and on-call policies on alerts and incidents
if (offlineInstance.data?.alerts) {
for (const alert of offlineInstance.data.alerts) {
alert.autoResolveAlert = autoResolve;
alert.onCallPolicyIds = alertOnCallPolicyIds.map(
(id: string) => new ObjectID(id),
);
}
}
if (offlineInstance.data?.incidents) {
for (const incident of offlineInstance.data.incidents) {
incident.autoResolveIncident = autoResolve;
incident.onCallPolicyIds = incidentOnCallPolicyIds.map(
(id: string) => new ObjectID(id),
);
}
}
const onlineInstance: MonitorCriteriaInstance = buildOnlineCriteriaInstance({
onlineMonitorStatusId,
metricAlias: props.metricAlias,
filterType: inverseFilterType,
value: thresholdValue,
});
const monitorCriteria: MonitorCriteria = new MonitorCriteria();
monitorCriteria.data = {
monitorCriteriaInstanceArray: [offlineInstance, onlineInstance],
};
props.onChange(monitorCriteria);
};
useEffect(() => {
buildAndEmitCriteria();
}, [
filterType,
thresholdValue,
alertSeverityId,
incidentSeverityId,
autoResolve,
alertOnCallPolicyIds,
incidentOnCallPolicyIds,
props.metricAlias,
props.monitorName,
]);
return (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Configure when this monitor should trigger an alert. The recovery
criteria will be auto-generated with the inverse condition.
</p>
{/* Threshold Row */}
<div>
<FieldLabelElement
title="Alert Condition"
description="When the metric value matches this condition, an alert will be triggered."
required={true}
/>
<div className="flex items-center space-x-3">
<div className="w-64">
<Dropdown
options={operatorOptions}
value={operatorOptions.find(
(o: DropdownOption) => o.value === filterType,
)}
onChange={(
value: DropdownValue | Array<DropdownValue> | null,
) => {
setFilterType(
(value as FilterType) || FilterType.GreaterThan,
);
}}
placeholder="Select operator..."
/>
</div>
<div className="w-32">
<Input
value={String(thresholdValue)}
type={InputType.NUMBER}
onChange={(value: string) => {
setThresholdValue(parseFloat(value) || 0);
}}
placeholder="Threshold"
/>
</div>
</div>
</div>
{/* Alert Severity */}
<div>
<FieldLabelElement
title="Alert Severity"
description="The severity level for alerts created by this monitor."
required={true}
/>
<Dropdown
options={props.alertSeverityDropdownOptions}
value={props.alertSeverityDropdownOptions.find(
(o: DropdownOption) => o.value?.toString() === alertSeverityId,
)}
onChange={(value: DropdownValue | Array<DropdownValue> | null) => {
setAlertSeverityId(value?.toString() || "");
}}
placeholder="Select alert severity..."
/>
</div>
{/* Incident Severity */}
<div>
<FieldLabelElement
title="Incident Severity"
description="The severity level for incidents created by this monitor."
required={true}
/>
<Dropdown
options={props.incidentSeverityDropdownOptions}
value={props.incidentSeverityDropdownOptions.find(
(o: DropdownOption) => o.value?.toString() === incidentSeverityId,
)}
onChange={(value: DropdownValue | Array<DropdownValue> | null) => {
setIncidentSeverityId(value?.toString() || "");
}}
placeholder="Select incident severity..."
/>
</div>
{/* Alert On-Call Policy */}
<div>
<FieldLabelElement
title="Alert On-Call Policy"
description="On-call policies to notify when an alert is created."
required={false}
/>
<Dropdown
options={props.onCallPolicyDropdownOptions}
value={props.onCallPolicyDropdownOptions.filter(
(o: DropdownOption) =>
alertOnCallPolicyIds.includes(o.value?.toString() || ""),
)}
onChange={(
value: DropdownValue | Array<DropdownValue> | null,
) => {
if (Array.isArray(value)) {
setAlertOnCallPolicyIds(
value.map((v: DropdownValue) => v.toString()),
);
} else if (value) {
setAlertOnCallPolicyIds([value.toString()]);
} else {
setAlertOnCallPolicyIds([]);
}
}}
isMultiSelect={true}
placeholder="Select on-call policies..."
/>
</div>
{/* Incident On-Call Policy */}
<div>
<FieldLabelElement
title="Incident On-Call Policy"
description="On-call policies to notify when an incident is created."
required={false}
/>
<Dropdown
options={props.onCallPolicyDropdownOptions}
value={props.onCallPolicyDropdownOptions.filter(
(o: DropdownOption) =>
incidentOnCallPolicyIds.includes(o.value?.toString() || ""),
)}
onChange={(
value: DropdownValue | Array<DropdownValue> | null,
) => {
if (Array.isArray(value)) {
setIncidentOnCallPolicyIds(
value.map((v: DropdownValue) => v.toString()),
);
} else if (value) {
setIncidentOnCallPolicyIds([value.toString()]);
} else {
setIncidentOnCallPolicyIds([]);
}
}}
isMultiSelect={true}
placeholder="Select on-call policies..."
/>
</div>
{/* Auto-Resolve */}
<div>
<Toggle
title="Auto-resolve when recovered"
description="Automatically resolve alerts and incidents when the metric returns to a healthy state."
value={autoResolve}
onChange={(value: boolean) => {
setAutoResolve(value);
}}
/>
</div>
{/* Summary */}
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
<p className="text-xs text-gray-600">
<strong>Summary:</strong> This monitor will create an alert and
incident when the metric is{" "}
<span className="font-mono bg-white px-1 rounded border">
{operatorOptions.find((o: DropdownOption) => o.value === filterType)
?.label || filterType}{" "}
{thresholdValue}
</span>
, and auto-recover when the condition clears.
</p>
</div>
</div>
);
};
export default KubernetesSimplifiedCriteriaForm;

View File

@@ -0,0 +1,161 @@
import React, { FunctionComponent, ReactElement } from "react";
import {
getAllKubernetesAlertTemplates,
KubernetesAlertTemplate,
KubernetesAlertTemplateCategory,
} from "Common/Types/Monitor/KubernetesAlertTemplates";
import IconProp from "Common/Types/Icon/IconProp";
import Icon from "Common/UI/Components/Icon/Icon";
export interface ComponentProps {
selectedTemplateId?: string | undefined;
onTemplateSelected: (template: KubernetesAlertTemplate) => void;
}
const categories: Array<{
category: KubernetesAlertTemplateCategory;
label: string;
icon: IconProp;
description: string;
}> = [
{
category: "Workload",
label: "Workload",
icon: IconProp.Cube,
description:
"Monitor workload health including pod restarts, replica mismatches, and job failures.",
},
{
category: "Node",
label: "Node",
icon: IconProp.Server,
description:
"Monitor node health including CPU, memory, disk usage, and node readiness.",
},
{
category: "ControlPlane",
label: "Control Plane",
icon: IconProp.Settings,
description:
"Monitor Kubernetes control plane components including etcd, API server, and scheduler.",
},
{
category: "Storage",
label: "Storage",
icon: IconProp.Disc,
description: "Monitor storage resources including disk usage.",
},
{
category: "Scheduling",
label: "Scheduling",
icon: IconProp.Clock,
description:
"Monitor pod scheduling including pending pods and scheduler backlog.",
},
];
const KubernetesTemplatePicker: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const allTemplates: Array<KubernetesAlertTemplate> =
getAllKubernetesAlertTemplates();
return (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Select a pre-built alert template to quickly set up monitoring. The
template will auto-configure the metric, scope, aggregation, time range,
and thresholds.
</p>
{categories.map(
(cat: {
category: KubernetesAlertTemplateCategory;
label: string;
icon: IconProp;
description: string;
}) => {
const categoryTemplates: Array<KubernetesAlertTemplate> =
allTemplates.filter(
(t: KubernetesAlertTemplate) => t.category === cat.category,
);
if (categoryTemplates.length === 0) {
return null;
}
return (
<div key={cat.category}>
<div className="flex items-center mb-2">
<Icon icon={cat.icon} className="mr-2 h-4 w-4 text-gray-500" />
<h4 className="text-sm font-semibold text-gray-700">
{cat.label}
</h4>
</div>
<p className="text-xs text-gray-400 mb-2">{cat.description}</p>
<div className="grid grid-cols-1 gap-2 mb-4">
{categoryTemplates.map((template: KubernetesAlertTemplate) => {
const isSelected: boolean =
props.selectedTemplateId === template.id;
return (
<div
key={template.id}
className={`cursor-pointer rounded-lg border p-3 transition-all hover:shadow-sm ${
isSelected
? "border-blue-500 bg-blue-50 ring-1 ring-blue-500"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
onClick={() => {
props.onTemplateSelected(template);
}}
role="button"
tabIndex={0}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
props.onTemplateSelected(template);
}
}}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center">
<span className="text-sm font-medium text-gray-900">
{template.name}
</span>
<span
className={`ml-2 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
template.severity === "Critical"
? "bg-red-100 text-red-800"
: "bg-yellow-100 text-yellow-800"
}`}
>
{template.severity}
</span>
</div>
<p className="mt-1 text-xs text-gray-500">
{template.description}
</p>
</div>
{isSelected && (
<div className="ml-3">
<Icon
icon={IconProp.CheckCircle}
className="h-5 w-5 text-blue-500"
/>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
},
)}
</div>
);
};
export default KubernetesTemplatePicker;

View File

@@ -67,6 +67,11 @@ import MetricMonitorStepForm from "./MetricMonitor/MetricMonitorStepForm";
import MonitorStepMetricMonitor, {
MonitorStepMetricMonitorUtil,
} from "Common/Types/Monitor/MonitorStepMetricMonitor";
import KubernetesMonitorStepForm from "./KubernetesMonitor/KubernetesMonitorStepForm";
import { KubernetesFormMode } from "./KubernetesMonitor/KubernetesMonitorStepForm";
import MonitorStepKubernetesMonitor, {
MonitorStepKubernetesMonitorUtil,
} from "Common/Types/Monitor/MonitorStepKubernetesMonitor";
import Link from "Common/UI/Components/Link/Link";
import TinyFormDocumentation from "Common/UI/Components/TinyFormDocumentation/TinyFormDocumentation";
import ExceptionMonitorStepForm from "./ExceptionMonitor/ExceptionMonitorStepForm";
@@ -129,6 +134,9 @@ const MonitorStepElement: FunctionComponent<ComponentProps> = (
const [error, setError] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(true);
const [kubernetesFormMode, setKubernetesFormMode] =
useState<KubernetesFormMode>("quick");
const fetchLogAttributes: PromiseVoidFunction = async (): Promise<void> => {
const attributeRepsonse: HTTPResponse<JSONObject> | HTTPErrorResponse =
await API.post({
@@ -742,6 +750,27 @@ return {
</Card>
)}
{props.monitorType === MonitorType.Kubernetes && (
<Card
title="Kubernetes Monitor Configuration"
description="Configure your Kubernetes cluster monitoring using templates, curated metrics, or the advanced query builder."
>
<KubernetesMonitorStepForm
monitorStepKubernetesMonitor={
monitorStep.data?.kubernetesMonitor ||
MonitorStepKubernetesMonitorUtil.getDefault()
}
onChange={(value: MonitorStepKubernetesMonitor) => {
monitorStep.setKubernetesMonitor(value);
props.onChange?.(MonitorStep.clone(monitorStep));
}}
onModeChange={(mode: KubernetesFormMode) => {
setKubernetesFormMode(mode);
}}
/>
</Card>
)}
{props.monitorType === MonitorType.Traces && (
<Card
title="Trace Monitor Configuration"

View File

@@ -6,6 +6,85 @@ import {
KubernetesContainerSpec,
KubernetesContainerStatus,
} from "../../Pages/Kubernetes/Utils/KubernetesObjectParser";
import StatusBadge, {
StatusBadgeType,
} from "Common/UI/Components/StatusBadge/StatusBadge";
import LocalTable from "Common/UI/Components/Table/LocalTable";
import FieldType from "Common/UI/Components/Types/FieldType";
import type Columns from "Common/UI/Components/Table/Types/Columns";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
function formatK8sResourceValue(key: string, value: string): string {
if (!value) {
return value;
}
// CPU values: millicores (e.g. "250m" = 0.25 cores)
const cpuMilliMatch: RegExpMatchArray | null = value.match(/^(\d+)m$/);
if (cpuMilliMatch && key.toLowerCase() === "cpu") {
const millis: number = parseInt(cpuMilliMatch[1] || "0");
if (millis >= 1000) {
return `${value} (${millis / 1000} CPU cores)`;
}
return `${value} (${(millis / 1000).toFixed(2)} CPU cores)`;
}
// CPU whole cores (e.g. "2" = 2 cores)
if (key.toLowerCase() === "cpu" && /^\d+$/.test(value)) {
const cores: number = parseInt(value);
return `${value} (${cores} CPU core${cores !== 1 ? "s" : ""})`;
}
// Memory values: Ki, Mi, Gi, Ti
const memMatch: RegExpMatchArray | null = value.match(
/^(\d+)(Ki|Mi|Gi|Ti)$/,
);
if (memMatch) {
const num: number = parseInt(memMatch[1] || "0");
const unit: string = memMatch[2] || "";
const explanations: Record<string, string> = {
Ki: `${(num / 1024).toFixed(num >= 1024 ? 1 : 2)} MB`,
Mi: num >= 1024 ? `${(num / 1024).toFixed(1)} GB` : `${num} MB`,
Gi: `${num} GB`,
Ti: `${num} TB`,
};
const readable: string | undefined = explanations[unit];
if (readable) {
return `${value} (${readable})`;
}
}
// Ephemeral storage: same units
const storageMatch: RegExpMatchArray | null = value.match(
/^(\d+)(K|M|G|T)$/,
);
if (storageMatch) {
const num: number = parseInt(storageMatch[1] || "0");
const unit: string = storageMatch[2] || "";
const explanations: Record<string, string> = {
K: `${(num / 1000).toFixed(num >= 1000 ? 1 : 2)} MB`,
M: num >= 1000 ? `${(num / 1000).toFixed(1)} GB` : `${num} MB`,
G: `${num} GB`,
T: `${num} TB`,
};
const readable: string | undefined = explanations[unit];
if (readable) {
return `${value} (${readable})`;
}
}
return value;
}
function annotateResourceValues(
resources: Record<string, string>,
): Record<string, string> {
const result: Record<string, string> = {};
for (const key of Object.keys(resources)) {
result[key] = formatK8sResourceValue(key, resources[key] || "");
}
return result;
}
export interface ComponentProps {
containers: Array<KubernetesContainerSpec>;
@@ -20,6 +99,49 @@ interface ContainerCardProps {
isInit: boolean;
}
interface VolumeMountRow {
name: string;
mountPath: string;
readOnly: string;
}
const volumeMountColumns: Columns<VolumeMountRow> = [
{
title: "Volume Name",
type: FieldType.Text,
key: "name",
},
{
title: "Mount Path",
type: FieldType.Element,
key: "mountPath",
getElement: (item: VolumeMountRow): ReactElement => {
return (
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded font-mono">
{item.mountPath}
</code>
);
},
},
{
title: "Access",
type: FieldType.Element,
key: "readOnly",
getElement: (item: VolumeMountRow): ReactElement => {
return (
<StatusBadge
text={item.readOnly === "true" ? "Read-Only" : "Read-Write"}
type={
item.readOnly === "true"
? StatusBadgeType.Warning
: StatusBadgeType.Neutral
}
/>
);
},
},
];
const ContainerCard: FunctionComponent<ContainerCardProps> = (
props: ContainerCardProps,
): ReactElement => {
@@ -40,62 +162,71 @@ const ContainerCard: FunctionComponent<ContainerCardProps> = (
title={`${props.isInit ? "Init Container: " : "Container: "}${props.container.name}`}
description={props.container.image}
>
<div className="space-y-4">
{/* Status */}
<div className="space-y-5">
{/* Status Info Cards */}
{props.status && (
<div className="flex gap-4 text-sm">
<div>
<span className="text-gray-500">State:</span>{" "}
<span
className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${
props.status.state === "running"
? "bg-green-50 text-green-700"
: props.status.state === "waiting"
? "bg-yellow-50 text-yellow-700"
: "bg-red-50 text-red-700"
}`}
>
{props.status.state}
</span>
</div>
<div>
<span className="text-gray-500">Ready:</span>{" "}
<span
className={
props.status.ready ? "text-green-700" : "text-red-700"
}
>
{props.status.ready ? "Yes" : "No"}
</span>
</div>
<div>
<span className="text-gray-500">Restarts:</span>{" "}
<span
className={
props.status.restartCount > 0
? "text-yellow-700"
: "text-gray-700"
}
>
{props.status.restartCount}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<InfoCard
title="State"
value={
<StatusBadge
text={props.status.state}
type={
props.status.state === "running"
? StatusBadgeType.Success
: props.status.state === "waiting"
? StatusBadgeType.Warning
: StatusBadgeType.Danger
}
/>
}
/>
<InfoCard
title="Ready"
value={
<StatusBadge
text={props.status.ready ? "Yes" : "No"}
type={
props.status.ready
? StatusBadgeType.Success
: StatusBadgeType.Danger
}
/>
}
/>
<InfoCard
title="Restarts"
value={
<StatusBadge
text={String(props.status.restartCount)}
type={
props.status.restartCount > 0
? StatusBadgeType.Warning
: StatusBadgeType.Neutral
}
/>
}
/>
</div>
)}
{/* Command & Args */}
{props.container.command.length > 0 && (
<div className="text-sm">
<span className="text-gray-500 font-medium">Command:</span>{" "}
<code className="text-xs bg-gray-100 px-1 py-0.5 rounded">
<div>
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">
Command
</div>
<code className="text-sm bg-gray-50 border border-gray-200 px-3 py-2 rounded-lg block font-mono text-gray-800">
{props.container.command.join(" ")}
</code>
</div>
)}
{props.container.args.length > 0 && (
<div className="text-sm">
<span className="text-gray-500 font-medium">Args:</span>{" "}
<code className="text-xs bg-gray-100 px-1 py-0.5 rounded">
<div>
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">
Args
</div>
<code className="text-sm bg-gray-50 border border-gray-200 px-3 py-2 rounded-lg block font-mono text-gray-800">
{props.container.args.join(" ")}
</code>
</div>
@@ -103,44 +234,50 @@ const ContainerCard: FunctionComponent<ContainerCardProps> = (
{/* Ports */}
{props.container.ports.length > 0 && (
<div className="text-sm">
<span className="text-gray-500 font-medium">Ports:</span>{" "}
{props.container.ports.map(
(port: KubernetesContainerPort, idx: number) => {
return (
<span
key={idx}
className="inline-flex px-2 py-0.5 text-xs font-medium rounded bg-blue-50 text-blue-700 mr-1"
>
{port.name ? `${port.name}: ` : ""}
{port.containerPort}/{port.protocol}
</span>
);
},
)}
<div>
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
Ports
</div>
<div className="flex flex-wrap gap-1.5">
{props.container.ports.map(
(port: KubernetesContainerPort, idx: number) => {
return (
<StatusBadge
key={idx}
text={`${port.name ? `${port.name}: ` : ""}${port.containerPort}/${port.protocol}`}
type={StatusBadgeType.Info}
/>
);
},
)}
</div>
</div>
)}
{/* Resources */}
{hasResources && (
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Object.keys(props.container.resources.requests).length > 0 && (
<div>
<span className="text-gray-500 font-medium block mb-1">
Requests:
</span>
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
Requests
</div>
<DictionaryOfStringsViewer
value={props.container.resources.requests}
value={annotateResourceValues(
props.container.resources.requests,
)}
/>
</div>
)}
{Object.keys(props.container.resources.limits).length > 0 && (
<div>
<span className="text-gray-500 font-medium block mb-1">
Limits:
</span>
<div className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">
Limits
</div>
<DictionaryOfStringsViewer
value={props.container.resources.limits}
value={annotateResourceValues(
props.container.resources.limits,
)}
/>
</div>
)}
@@ -154,60 +291,56 @@ const ContainerCard: FunctionComponent<ContainerCardProps> = (
onClick={() => {
setShowEnv(!showEnv);
}}
className="text-sm text-indigo-600 hover:text-indigo-900 font-medium"
className="flex items-center gap-1.5 text-sm text-indigo-600 hover:text-indigo-800 font-medium transition-colors"
>
{showEnv ? "Hide" : "Show"} Environment Variables (
{props.container.env.length})
<span className="text-xs">
{showEnv ? "▼" : "▶"}
</span>
Environment Variables ({props.container.env.length})
</button>
{showEnv && (
<div className="mt-2">
<div className="mt-3">
<DictionaryOfStringsViewer value={envRecord} />
</div>
)}
</div>
)}
{/* Volume Mounts (expandable) */}
{/* Volume Mounts (expandable with table) */}
{props.container.volumeMounts.length > 0 && (
<div>
<button
onClick={() => {
setShowMounts(!showMounts);
}}
className="text-sm text-indigo-600 hover:text-indigo-900 font-medium"
className="flex items-center gap-1.5 text-sm text-indigo-600 hover:text-indigo-800 font-medium transition-colors"
>
{showMounts ? "Hide" : "Show"} Volume Mounts (
{props.container.volumeMounts.length})
<span className="text-xs">
{showMounts ? "▼" : "▶"}
</span>
Volume Mounts ({props.container.volumeMounts.length})
</button>
{showMounts && (
<div className="mt-2 space-y-1">
{props.container.volumeMounts.map(
(
mount: {
<div className="mt-3">
<LocalTable
id={`volume-mounts-${props.container.name}`}
data={props.container.volumeMounts.map(
(mount: {
name: string;
mountPath: string;
readOnly: boolean;
}): VolumeMountRow => {
return {
name: mount.name,
mountPath: mount.mountPath,
readOnly: String(mount.readOnly),
};
},
idx: number,
) => {
return (
<div key={idx} className="text-sm flex gap-2">
<span className="font-medium text-gray-700">
{mount.name}
</span>
<span className="text-gray-500"></span>
<code className="text-xs bg-gray-100 px-1 py-0.5 rounded">
{mount.mountPath}
</code>
{mount.readOnly && (
<span className="text-xs text-gray-400">
(read-only)
</span>
)}
</div>
);
},
)}
)}
columns={volumeMountColumns}
singularLabel="Mount"
pluralLabel="Mounts"
/>
</div>
)}
</div>

View File

@@ -0,0 +1,214 @@
import React, { FunctionComponent, ReactElement, useState } from "react";
import Card from "Common/UI/Components/Card/Card";
import {
KubernetesContainerEnvVar,
KubernetesContainerSpec,
} from "../../Pages/Kubernetes/Utils/KubernetesObjectParser";
import StatusBadge, {
StatusBadgeType,
} from "Common/UI/Components/StatusBadge/StatusBadge";
import LocalTable from "Common/UI/Components/Table/LocalTable";
import FieldType from "Common/UI/Components/Types/FieldType";
import type Columns from "Common/UI/Components/Table/Types/Columns";
import Icon from "Common/UI/Components/Icon/Icon";
import IconProp from "Common/Types/Icon/IconProp";
export interface ComponentProps {
containers: Array<KubernetesContainerSpec>;
initContainers: Array<KubernetesContainerSpec>;
}
interface EnvVarRow {
name: string;
value: string;
}
const KubernetesEnvVarsTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [search, setSearch] = useState<string>("");
const allContainers: Array<KubernetesContainerSpec> = [
...props.initContainers,
...props.containers,
];
if (allContainers.length === 0) {
return (
<div className="text-gray-500 text-sm p-4">
No container information available.
</div>
);
}
const totalEnvCount: number = allContainers.reduce(
(sum: number, c: KubernetesContainerSpec) => {
return sum + c.env.length;
},
0,
);
if (totalEnvCount === 0) {
return (
<div className="text-gray-500 text-sm p-4">
No environment variables defined for any container.
</div>
);
}
const searchLower: string = search.toLowerCase();
const totalMatchCount: number = search
? allContainers.reduce((sum: number, c: KubernetesContainerSpec) => {
return (
sum +
c.env.filter((env: KubernetesContainerEnvVar) => {
return (
env.name.toLowerCase().includes(searchLower) ||
env.value.toLowerCase().includes(searchLower)
);
}).length
);
}, 0)
: totalEnvCount;
const columns: Columns<EnvVarRow> = [
{
title: "Name",
type: FieldType.Element,
key: "name",
getElement: (item: EnvVarRow): ReactElement => {
return (
<span className="font-mono font-medium text-gray-900">
{item.name}
</span>
);
},
},
{
title: "Value",
type: FieldType.Element,
key: "value",
getElement: (item: EnvVarRow): ReactElement => {
const isSecret: boolean =
item.value.startsWith("<Secret:") ||
item.value.startsWith("<ConfigMap:") ||
item.value.startsWith("<FieldRef:") ||
item.value.startsWith("<ResourceFieldRef:");
if (isSecret) {
return (
<StatusBadge
text={item.value}
type={
item.value.startsWith("<Secret:")
? StatusBadgeType.Warning
: StatusBadgeType.Info
}
/>
);
}
return (
<span className="font-mono text-gray-600">
{item.value || (
<span className="text-gray-400 italic">empty</span>
)}
</span>
);
},
},
];
return (
<div className="space-y-4">
{/* Search bar */}
<div className="px-4 pt-4">
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Icon
icon={IconProp.Search}
className="h-4 w-4 text-gray-400"
/>
</div>
<input
type="text"
placeholder="Search by name or value..."
value={search}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
}}
className="block w-full rounded-md border border-gray-300 bg-white py-2 pl-9 pr-20 text-sm placeholder-gray-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-2">
{search && (
<span className="text-xs text-gray-400 tabular-nums">
{totalMatchCount}/{totalEnvCount}
</span>
)}
{search && (
<button
onClick={() => {
setSearch("");
}}
className="rounded p-0.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
>
<Icon icon={IconProp.Close} className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
</div>
{allContainers.map(
(container: KubernetesContainerSpec, containerIdx: number) => {
if (container.env.length === 0) {
return null;
}
const filteredEnv: Array<KubernetesContainerEnvVar> = search
? container.env.filter((env: KubernetesContainerEnvVar) => {
return (
env.name.toLowerCase().includes(searchLower) ||
env.value.toLowerCase().includes(searchLower)
);
})
: container.env;
if (filteredEnv.length === 0) {
return null;
}
const isInit: boolean = containerIdx < props.initContainers.length;
const tableData: Array<EnvVarRow> = filteredEnv.map(
(env: KubernetesContainerEnvVar): EnvVarRow => {
return {
name: env.name,
value: env.value,
};
},
);
return (
<Card
key={containerIdx}
title={`${isInit ? "Init Container: " : ""}${container.name}`}
description={`${filteredEnv.length} environment variable${filteredEnv.length !== 1 ? "s" : ""}`}
>
<LocalTable
id={`env-vars-${containerIdx}`}
data={tableData}
columns={columns}
singularLabel="Variable"
pluralLabel="Variables"
/>
</Card>
);
},
)}
</div>
);
};
export default KubernetesEnvVarsTab;

View File

@@ -10,6 +10,15 @@ 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";
import ExpandableText from "Common/UI/Components/ExpandableText/ExpandableText";
import LocalTable from "Common/UI/Components/Table/LocalTable";
import FieldType from "Common/UI/Components/Types/FieldType";
import type Columns from "Common/UI/Components/Table/Types/Columns";
export interface ComponentProps {
clusterIdentifier: string;
@@ -18,26 +27,64 @@ export interface ComponentProps {
namespace?: string | undefined;
}
interface EventRow {
timestamp: string;
relativeTime: string;
type: string;
reason: string;
message: string;
}
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 KubernetesEventsTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [events, setEvents] = useState<Array<KubernetesEvent>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [typeFilter, setTypeFilter] = useState<string>("all");
useEffect(() => {
const fetchEvents: () => Promise<void> = async (): Promise<void> => {
setIsLoading(true);
try {
const result: Array<KubernetesEvent> = await fetchK8sEventsForResource({
clusterIdentifier: props.clusterIdentifier,
resourceKind: props.resourceKind,
resourceName: props.resourceName,
namespace: props.namespace,
});
const result: Array<KubernetesEvent> =
await fetchK8sEventsForResource({
clusterIdentifier: props.clusterIdentifier,
resourceKind: props.resourceKind,
resourceName: props.resourceName,
namespace: props.namespace,
});
setEvents(result);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch events");
setError(
err instanceof Error ? err.message : "Failed to fetch events",
);
}
setIsLoading(false);
};
@@ -67,55 +114,116 @@ const KubernetesEventsTab: FunctionComponent<ComponentProps> = (
);
}
const warningCount: number = events.filter(
(e: KubernetesEvent) => e.type.toLowerCase() === "warning",
).length;
const normalCount: number = events.length - warningCount;
const filteredEvents: Array<KubernetesEvent> = events.filter(
(e: KubernetesEvent) => {
if (typeFilter === "warning") {
return e.type.toLowerCase() === "warning";
}
if (typeFilter === "normal") {
return e.type.toLowerCase() !== "warning";
}
return true;
},
);
const filterOptions: Array<FilterButtonOption> = [
{ label: "All", value: "all" },
{ label: "Warnings", value: "warning", badge: warningCount },
{ label: "Normal", value: "normal", badge: normalCount },
];
const tableData: Array<EventRow> = filteredEvents.map(
(event: KubernetesEvent): EventRow => {
return {
timestamp: event.timestamp,
relativeTime: formatRelativeTime(event.timestamp),
type: event.type,
reason: event.reason,
message: event.message,
};
},
);
const columns: Columns<EventRow> = [
{
title: "Time",
type: FieldType.Text,
key: "relativeTime",
tooltipText: (item: EventRow): string => {
return item.timestamp;
},
},
{
title: "Type",
type: FieldType.Element,
key: "type",
getElement: (item: EventRow): ReactElement => {
const isWarning: boolean = item.type.toLowerCase() === "warning";
return (
<StatusBadge
text={item.type}
type={
isWarning ? StatusBadgeType.Warning : StatusBadgeType.Success
}
/>
);
},
},
{
title: "Reason",
type: FieldType.Text,
key: "reason",
},
{
title: "Message",
type: FieldType.Element,
key: "message",
getElement: (item: EventRow): ReactElement => {
return <ExpandableText text={item.message} maxLength={120} />;
},
},
];
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Time
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Type
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Reason
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Message
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{events.map((event: KubernetesEvent, index: number) => {
const isWarning: boolean = event.type.toLowerCase() === "warning";
return (
<tr key={index} className={isWarning ? "bg-yellow-50" : ""}>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
{event.timestamp}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
isWarning
? "bg-yellow-100 text-yellow-800"
: "bg-green-100 text-green-800"
}`}
>
{event.type}
</span>
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-900">
{event.reason}
</td>
<td className="px-4 py-3 text-sm text-gray-500 max-w-lg">
{event.message}
</td>
</tr>
);
})}
</tbody>
</table>
<div>
{/* Summary and Filters */}
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<div className="text-sm text-gray-600">
<span className="font-medium">{events.length}</span> events
{warningCount > 0 && (
<span>
{" "}
(
<span className="text-amber-700 font-medium">
{warningCount}
</span>{" "}
warning{warningCount !== 1 ? "s" : ""},{" "}
<span className="text-emerald-700 font-medium">
{normalCount}
</span>{" "}
normal)
</span>
)}
</div>
<FilterButtons
options={filterOptions}
selectedValue={typeFilter}
onSelect={setTypeFilter}
/>
</div>
<LocalTable
id="kubernetes-events-table"
data={tableData}
columns={columns}
singularLabel="Event"
pluralLabel="Events"
/>
</div>
);
};

View File

@@ -1,15 +1,7 @@
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import {
fetchPodLogs,
KubernetesLogEntry,
} from "../../Pages/Kubernetes/Utils/KubernetesObjectFetcher";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import React, { FunctionComponent, ReactElement, useMemo } from "react";
import DashboardLogsViewer from "../Logs/LogsViewer";
import Query from "Common/Types/BaseDatabase/Query";
import Log from "Common/Models/AnalyticsModels/Log";
export interface ComponentProps {
clusterIdentifier: string;
@@ -21,29 +13,23 @@ export interface ComponentProps {
const KubernetesLogsTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [logs, setLogs] = useState<Array<KubernetesLogEntry>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
useEffect(() => {
const fetchLogs: () => Promise<void> = async (): Promise<void> => {
setIsLoading(true);
try {
const result: Array<KubernetesLogEntry> = await fetchPodLogs({
clusterIdentifier: props.clusterIdentifier,
podName: props.podName,
containerName: props.containerName,
namespace: props.namespace,
limit: 500,
});
setLogs(result);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch logs");
}
setIsLoading(false);
const logQuery: Query<Log> = useMemo(() => {
const attributeFilters: Record<string, string> = {
"resource.k8s.cluster.name": props.clusterIdentifier,
"resource.k8s.pod.name": props.podName,
};
fetchLogs().catch(() => {});
if (props.containerName) {
attributeFilters["resource.k8s.container.name"] = props.containerName;
}
if (props.namespace) {
attributeFilters["resource.k8s.namespace.name"] = props.namespace;
}
return {
attributes: attributeFilters,
} as Query<Log>;
}, [
props.clusterIdentifier,
props.podName,
@@ -51,65 +37,13 @@ const KubernetesLogsTab: FunctionComponent<ComponentProps> = (
props.namespace,
]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (logs.length === 0) {
return (
<div className="text-gray-500 text-sm p-4">
No application logs found for this pod in the last 6 hours. Logs will
appear here once the kubernetes-agent&apos;s filelog receiver is
collecting data.
</div>
);
}
const getSeverityColor: (severity: string) => string = (
severity: string,
): string => {
const s: string = severity.toUpperCase();
if (s === "ERROR" || s === "FATAL" || s === "CRITICAL") {
return "text-red-600";
}
if (s === "WARN" || s === "WARNING") {
return "text-yellow-600";
}
if (s === "DEBUG" || s === "TRACE") {
return "text-gray-400";
}
return "text-gray-700";
};
return (
<div className="bg-gray-900 rounded-lg p-4 overflow-auto max-h-[600px] font-mono text-xs">
{logs.map((log: KubernetesLogEntry, index: number) => {
return (
<div key={index} className="flex gap-2 py-0.5 hover:bg-gray-800">
<span className="text-gray-500 whitespace-nowrap flex-shrink-0">
{log.timestamp}
</span>
{log.containerName && (
<span className="text-blue-400 whitespace-nowrap flex-shrink-0">
[{log.containerName}]
</span>
)}
<span
className={`whitespace-nowrap flex-shrink-0 w-12 ${getSeverityColor(log.severity)}`}
>
{log.severity}
</span>
<span className="text-gray-200 whitespace-pre-wrap break-all">
{log.body}
</span>
</div>
);
})}
</div>
<DashboardLogsViewer
id={`k8s-logs-${props.podName}`}
logQuery={logQuery}
showFilters={true}
noLogsMessage="No application logs found for this pod. Logs will appear here once the kubernetes-agent's filelog receiver is collecting data."
/>
);
};

View File

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

View File

@@ -4,6 +4,10 @@ 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";
import ConditionsTable from "Common/UI/Components/ConditionsTable/ConditionsTable";
import type { Condition } from "Common/UI/Components/ConditionsTable/ConditionsTable";
import ObjectID from "Common/Types/ObjectID";
import KubernetesResourceLink from "./KubernetesResourceLink";
export interface SummaryField {
title: string;
@@ -16,6 +20,7 @@ export interface ComponentProps {
annotations: Record<string, string>;
conditions?: Array<KubernetesCondition> | undefined;
ownerReferences?: Array<{ kind: string; name: string }> | undefined;
modelId?: ObjectID | undefined;
isLoading: boolean;
emptyMessage?: string | undefined;
}
@@ -39,6 +44,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 */}
@@ -66,7 +82,15 @@ const KubernetesOverviewTab: FunctionComponent<ComponentProps> = (
<span className="font-medium text-gray-700">
{ref.kind}:
</span>{" "}
<span className="text-gray-600">{ref.name}</span>
{props.modelId ? (
<KubernetesResourceLink
modelId={props.modelId}
resourceKind={ref.kind}
resourceName={ref.name}
/>
) : (
<span className="text-gray-600">{ref.name}</span>
)}
</div>
);
},
@@ -76,69 +100,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) => {
return (
<tr key={index}>
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
{condition.type}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">
<span
className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${
condition.status === "True"
? "bg-green-50 text-green-700"
: condition.status === "False"
? "bg-red-50 text-red-700"
: "bg-gray-50 text-gray-700"
}`}
>
{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 text-gray-600 max-w-md truncate">
{condition.message || "-"}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
{condition.lastTransitionTime || "-"}
</td>
</tr>
);
},
)}
</tbody>
</table>
</div>
<ConditionsTable conditions={conditions} />
</Card>
)}

View File

@@ -0,0 +1,57 @@
import React, { FunctionComponent, ReactElement } from "react";
import Navigation from "Common/UI/Utils/Navigation";
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
import PageMap from "../../Utils/PageMap";
import Route from "Common/Types/API/Route";
import ObjectID from "Common/Types/ObjectID";
// Maps Kubernetes resource kinds to their detail page PageMap entries
const kindToPageMap: Record<string, PageMap> = {
Pod: PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL,
Node: PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL,
Namespace: PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL,
Deployment: PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL,
StatefulSet: PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL,
DaemonSet: PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL,
Job: PageMap.KUBERNETES_CLUSTER_VIEW_JOB_DETAIL,
CronJob: PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL,
PersistentVolumeClaim: PageMap.KUBERNETES_CLUSTER_VIEW_PVC_DETAIL,
PersistentVolume: PageMap.KUBERNETES_CLUSTER_VIEW_PV_DETAIL,
ReplicaSet: PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL, // ReplicaSets are managed by Deployments
};
export interface ComponentProps {
modelId: ObjectID;
resourceKind: string;
resourceName: string;
className?: string | undefined;
}
const KubernetesResourceLink: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const pageMap: PageMap | undefined = kindToPageMap[props.resourceKind];
if (!pageMap) {
// No detail page for this kind — render as plain text
return <span className={props.className}>{props.resourceName}</span>;
}
return (
<span
onClick={() => {
Navigation.navigate(
RouteUtil.populateRouteParams(RouteMap[pageMap] as Route, {
modelId: props.modelId,
subModelId: new ObjectID(props.resourceName),
}),
);
}}
className={`text-indigo-600 hover:text-indigo-800 cursor-pointer font-medium ${props.className || ""}`}
>
{props.resourceName}
</span>
);
};
export default KubernetesResourceLink;

View File

@@ -1,14 +1,25 @@
import React, { FunctionComponent, ReactElement } from "react";
import React, {
FunctionComponent,
ReactElement,
useMemo,
useState,
} from "react";
import KubernetesResourceUtils, {
KubernetesResource,
} from "../../Pages/Kubernetes/Utils/KubernetesResourceUtils";
import Card from "Common/UI/Components/Card/Card";
import Card, { CardButtonSchema } from "Common/UI/Components/Card/Card";
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
import IconProp from "Common/Types/Icon/IconProp";
import Table from "Common/UI/Components/Table/Table";
import FieldType from "Common/UI/Components/Types/FieldType";
import Link from "Common/UI/Components/Link/Link";
import SortOrder from "Common/Types/BaseDatabase/SortOrder";
import Route from "Common/Types/API/Route";
import Column from "Common/UI/Components/Table/Types/Column";
import Filter from "Common/UI/Components/Filters/Types/Filter";
import FilterData from "Common/UI/Components/Filters/Types/FilterData";
import Search from "Common/Types/BaseDatabase/Search";
import Includes from "Common/Types/BaseDatabase/Includes";
export interface ResourceColumn {
title: string;
@@ -22,21 +33,218 @@ export interface ComponentProps {
description: string;
columns?: Array<ResourceColumn>;
showNamespace?: boolean;
showStatus?: boolean;
showResourceMetrics?: boolean;
getViewRoute?: (resource: KubernetesResource) => Route;
emptyMessage?: string;
isLoading?: boolean;
}
const PAGE_SIZE: number = 25;
function getStatusBadgeClass(status: string): string {
const s: string = status.toLowerCase();
if (
s === "running" ||
s === "ready" ||
s === "active" ||
s === "bound" ||
s === "succeeded" ||
s === "available" ||
s === "true"
) {
return "bg-green-50 text-green-700";
}
if (
s === "pending" ||
s === "unknown" ||
s === "waiting" ||
s === "terminating"
) {
return "bg-yellow-50 text-yellow-700";
}
if (
s === "failed" ||
s === "crashloopbackoff" ||
s === "error" ||
s === "lost" ||
s === "notready" ||
s === "imagepullbackoff" ||
s === "false"
) {
return "bg-red-50 text-red-700";
}
return "bg-gray-50 text-gray-700";
}
function getCpuBarColor(pct: number): string {
if (pct > 80) {
return "bg-red-500";
}
if (pct > 60) {
return "bg-yellow-500";
}
return "bg-green-500";
}
function getMemoryBarColor(pct: number): string {
if (pct > 85) {
return "bg-red-500";
}
if (pct > 70) {
return "bg-yellow-500";
}
return "bg-blue-500";
}
const KubernetesResourceTable: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const showNamespace: boolean = props.showNamespace !== false;
const showStatus: boolean = props.showStatus !== false;
const showResourceMetrics: boolean = props.showResourceMetrics !== false;
const [currentPage, setCurrentPage] = useState<number>(1);
const [sortBy, setSortBy] = useState<string | null>(null);
const [sortOrder, setSortOrder] = useState<SortOrder>(SortOrder.Ascending);
const [showFilterModal, setShowFilterModal] = useState<boolean>(false);
const [filterData, setFilterData] = useState<
FilterData<KubernetesResource>
>({});
// Build filter definitions from data
const filters: Array<Filter<KubernetesResource>> = useMemo(() => {
const result: Array<Filter<KubernetesResource>> = [
{
title: "Name",
key: "name",
type: FieldType.Text,
},
];
if (showNamespace) {
const namespaces: Array<string> = Array.from(
new Set(
props.resources
.map((r: KubernetesResource) => {
return r.namespace;
})
.filter(Boolean),
),
).sort();
result.push({
title: "Namespace",
key: "namespace",
type: FieldType.Dropdown,
filterDropdownOptions: namespaces.map((ns: string) => {
return { label: ns, value: ns };
}),
});
}
if (showStatus) {
const statuses: Array<string> = Array.from(
new Set(
props.resources
.map((r: KubernetesResource) => {
return r.status;
})
.filter(Boolean),
),
).sort();
result.push({
title: "Status",
key: "status",
type: FieldType.Dropdown,
filterDropdownOptions: statuses.map((s: string) => {
return { label: s, value: s };
}),
});
}
return result;
}, [props.resources, showNamespace, showStatus]);
// Filter and sort data client-side
const processedData: Array<KubernetesResource> = useMemo(() => {
let data: Array<KubernetesResource> = [...props.resources];
// Apply filters from filterData
for (const key of Object.keys(filterData) as Array<
keyof KubernetesResource
>) {
const value: unknown = filterData[key];
if (!value) {
continue;
}
if (value instanceof Search) {
const searchText: string = value.toString().toLowerCase();
data = data.filter((r: KubernetesResource) => {
const fieldValue: string = (r[key] as string) || "";
return fieldValue.toLowerCase().includes(searchText);
});
} else if (value instanceof Includes) {
const includeValues: Array<string> =
value.values as Array<string>;
data = data.filter((r: KubernetesResource) => {
const fieldValue: string = (r[key] as string) || "";
return includeValues.includes(fieldValue);
});
} else if (typeof value === "string") {
// Dropdown single selection stores as plain string
data = data.filter((r: KubernetesResource) => {
const fieldValue: string = (r[key] as string) || "";
return fieldValue === value;
});
} else if (Array.isArray(value)) {
// Dropdown multi-selection stores as plain array
const includeValues: Array<string> = value.map(
(v: unknown) => {
return String(v);
},
);
data = data.filter((r: KubernetesResource) => {
const fieldValue: string = (r[key] as string) || "";
return includeValues.includes(fieldValue);
});
}
}
// Sort
if (sortBy) {
data.sort((a: KubernetesResource, b: KubernetesResource) => {
let cmp: number = 0;
if (sortBy === "name") {
cmp = a.name.localeCompare(b.name);
} else if (sortBy === "namespace") {
cmp = a.namespace.localeCompare(b.namespace);
} else if (sortBy === "status") {
cmp = a.status.localeCompare(b.status);
} else if (sortBy === "cpuUtilization") {
cmp = (a.cpuUtilization ?? -1) - (b.cpuUtilization ?? -1);
} else if (sortBy === "memoryUsageBytes") {
cmp = (a.memoryUsageBytes ?? -1) - (b.memoryUsageBytes ?? -1);
} else if (sortBy === "age") {
cmp = a.age.localeCompare(b.age);
}
return sortOrder === SortOrder.Descending ? -cmp : cmp;
});
}
return data;
}, [props.resources, filterData, sortBy, sortOrder]);
// Paginate
const paginatedData: Array<KubernetesResource> = useMemo(() => {
const start: number = (currentPage - 1) * PAGE_SIZE;
return processedData.slice(start, start + PAGE_SIZE);
}, [processedData, currentPage]);
const tableColumns: Array<Column<KubernetesResource>> = [
{
title: "Name",
type: FieldType.Element,
key: "name",
disableSort: true,
getElement: (resource: KubernetesResource): ReactElement => {
return (
<span className="font-medium text-gray-900">{resource.name}</span>
@@ -50,7 +258,6 @@ const KubernetesResourceTable: FunctionComponent<ComponentProps> = (
title: "Namespace",
type: FieldType.Element,
key: "namespace",
disableSort: true,
getElement: (resource: KubernetesResource): ReactElement => {
return (
<span className="inline-flex px-2 py-0.5 text-xs font-medium rounded bg-blue-50 text-blue-700">
@@ -61,6 +268,26 @@ const KubernetesResourceTable: FunctionComponent<ComponentProps> = (
});
}
if (showStatus) {
tableColumns.push({
title: "Status",
type: FieldType.Element,
key: "status",
getElement: (resource: KubernetesResource): ReactElement => {
if (!resource.status) {
return <span className="text-gray-400">-</span>;
}
return (
<span
className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${getStatusBadgeClass(resource.status)}`}
>
{resource.status}
</span>
);
},
});
}
if (props.columns) {
for (const col of props.columns) {
tableColumns.push({
@@ -78,49 +305,113 @@ const KubernetesResourceTable: FunctionComponent<ComponentProps> = (
}
}
tableColumns.push(
{
title: "CPU",
type: FieldType.Element,
key: "cpuUtilization",
disableSort: true,
getElement: (resource: KubernetesResource): ReactElement => {
return (
<span
className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${
resource.cpuUtilization !== null && resource.cpuUtilization > 80
? "bg-red-50 text-red-700"
: resource.cpuUtilization !== null &&
resource.cpuUtilization > 60
? "bg-yellow-50 text-yellow-700"
: "bg-green-50 text-green-700"
}`}
>
{KubernetesResourceUtils.formatCpuValue(resource.cpuUtilization)}
</span>
);
if (showResourceMetrics) {
tableColumns.push(
{
title: "CPU",
type: FieldType.Element,
key: "cpuUtilization",
getElement: (resource: KubernetesResource): ReactElement => {
if (
resource.cpuUtilization === null ||
resource.cpuUtilization === undefined
) {
return <span className="text-gray-400">N/A</span>;
}
const pct: number = Math.min(resource.cpuUtilization, 100);
return (
<div className="flex items-center gap-2 min-w-[120px]">
<div className="flex-1 bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${getCpuBarColor(pct)}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-gray-600 whitespace-nowrap w-10 text-right">
{KubernetesResourceUtils.formatCpuValue(
resource.cpuUtilization,
)}
</span>
</div>
);
},
},
},
{
title: "Memory",
type: FieldType.Element,
key: "memoryUsageBytes",
disableSort: true,
getElement: (resource: KubernetesResource): ReactElement => {
return (
<span>
{KubernetesResourceUtils.formatMemoryValue(
resource.memoryUsageBytes,
)}
</span>
);
{
title: "Memory",
type: FieldType.Element,
key: "memoryUsageBytes",
getElement: (resource: KubernetesResource): ReactElement => {
if (
resource.memoryUsageBytes === null ||
resource.memoryUsageBytes === undefined
) {
return <span className="text-gray-400">N/A</span>;
}
if (
resource.memoryLimitBytes !== null &&
resource.memoryLimitBytes !== undefined &&
resource.memoryLimitBytes > 0
) {
const pct: number = Math.min(
(resource.memoryUsageBytes / resource.memoryLimitBytes) * 100,
100,
);
return (
<div className="min-w-[140px]">
<div className="flex items-center gap-2">
<div className="flex-1 bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full ${getMemoryBarColor(pct)}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-gray-600 whitespace-nowrap">
{Math.round(pct)}%
</span>
</div>
<div className="text-xs text-gray-500 mt-0.5">
{KubernetesResourceUtils.formatMemoryValue(
resource.memoryUsageBytes,
)}{" "}
/{" "}
{KubernetesResourceUtils.formatMemoryValue(
resource.memoryLimitBytes,
)}
</div>
</div>
);
}
return (
<span className="text-sm text-gray-700">
{KubernetesResourceUtils.formatMemoryValue(
resource.memoryUsageBytes,
)}
</span>
);
},
},
},
);
);
}
if (showStatus) {
tableColumns.push({
title: "Age",
type: FieldType.Element,
key: "age",
getElement: (resource: KubernetesResource): ReactElement => {
if (!resource.age) {
return <span className="text-gray-400">-</span>;
}
return <span className="text-sm text-gray-600">{resource.age}</span>;
},
});
}
if (props.getViewRoute) {
tableColumns.push({
title: "Actions",
title: "",
type: FieldType.Element,
key: "name",
disableSort: true,
@@ -137,27 +428,67 @@ const KubernetesResourceTable: FunctionComponent<ComponentProps> = (
});
}
const hasActiveFilters: boolean = Object.keys(filterData).length > 0;
const cardButtons: Array<CardButtonSchema> = [
{
title: "",
buttonStyle: ButtonStyleType.ICON,
className: "py-0 pr-0 pl-1 mt-1",
onClick: () => {
setShowFilterModal(true);
},
icon: IconProp.Filter,
},
];
return (
<Card title={props.title} description={props.description}>
<Card
title={props.title}
description={props.description}
buttons={cardButtons}
>
<Table<KubernetesResource>
id={`kubernetes-${props.title.toLowerCase().replace(/\s+/g, "-")}-table`}
columns={tableColumns}
data={props.resources}
data={paginatedData}
singularLabel={props.title}
pluralLabel={props.title}
isLoading={false}
isLoading={props.isLoading || false}
error=""
disablePagination={true}
currentPageNumber={1}
totalItemsCount={props.resources.length}
itemsOnPage={props.resources.length}
onNavigateToPage={() => {}}
sortBy={null}
sortOrder={SortOrder.Ascending}
onSortChanged={() => {}}
currentPageNumber={currentPage}
totalItemsCount={processedData.length}
itemsOnPage={paginatedData.length}
onNavigateToPage={(page: number) => {
setCurrentPage(page);
}}
sortBy={sortBy as keyof KubernetesResource | null}
sortOrder={sortOrder}
onSortChanged={(
newSortBy: keyof KubernetesResource | null,
newSortOrder: SortOrder,
) => {
setSortBy(newSortBy as string | null);
setSortOrder(newSortOrder);
}}
filters={filters}
showFilterModal={showFilterModal}
filterData={filterData}
onFilterChanged={(newFilterData: FilterData<KubernetesResource>) => {
setFilterData(newFilterData);
setCurrentPage(1);
}}
onFilterModalOpen={() => {
setShowFilterModal(true);
}}
onFilterModalClose={() => {
setShowFilterModal(false);
}}
noItemsMessage={
props.emptyMessage ||
"No resources found. Resources will appear here once the kubernetes-agent is sending data."
hasActiveFilters
? "No resources match the current filters."
: props.emptyMessage ||
"No resources found. Resources will appear here once the kubernetes-agent is sending data."
}
/>
</Card>

View File

@@ -0,0 +1,221 @@
import React, { FunctionComponent, ReactElement, useState } from "react";
import Card from "Common/UI/Components/Card/Card";
import { KubernetesContainerSpec } from "../../Pages/Kubernetes/Utils/KubernetesObjectParser";
import StatusBadge, {
StatusBadgeType,
} from "Common/UI/Components/StatusBadge/StatusBadge";
import LocalTable from "Common/UI/Components/Table/LocalTable";
import FieldType from "Common/UI/Components/Types/FieldType";
import type Columns from "Common/UI/Components/Table/Types/Columns";
import Icon from "Common/UI/Components/Icon/Icon";
import IconProp from "Common/Types/Icon/IconProp";
export interface ComponentProps {
containers: Array<KubernetesContainerSpec>;
initContainers: Array<KubernetesContainerSpec>;
}
interface VolumeMountRow {
name: string;
mountPath: string;
readOnly: string;
}
const KubernetesVolumeMountsTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [search, setSearch] = useState<string>("");
const allContainers: Array<KubernetesContainerSpec> = [
...props.initContainers,
...props.containers,
];
if (allContainers.length === 0) {
return (
<div className="text-gray-500 text-sm p-4">
No container information available.
</div>
);
}
const totalMountCount: number = allContainers.reduce(
(sum: number, c: KubernetesContainerSpec) => {
return sum + c.volumeMounts.length;
},
0,
);
if (totalMountCount === 0) {
return (
<div className="text-gray-500 text-sm p-4">
No volume mounts defined for any container.
</div>
);
}
const searchLower: string = search.toLowerCase();
const totalMatchCount: number = search
? allContainers.reduce((sum: number, c: KubernetesContainerSpec) => {
return (
sum +
c.volumeMounts.filter(
(m: { name: string; mountPath: string; readOnly: boolean }) => {
return (
m.name.toLowerCase().includes(searchLower) ||
m.mountPath.toLowerCase().includes(searchLower)
);
},
).length
);
}, 0)
: totalMountCount;
const columns: Columns<VolumeMountRow> = [
{
title: "Volume Name",
type: FieldType.Element,
key: "name",
getElement: (item: VolumeMountRow): ReactElement => {
return (
<span className="font-mono font-medium text-gray-900">
{item.name}
</span>
);
},
},
{
title: "Mount Path",
type: FieldType.Element,
key: "mountPath",
getElement: (item: VolumeMountRow): ReactElement => {
return (
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded font-mono">
{item.mountPath}
</code>
);
},
},
{
title: "Access",
type: FieldType.Element,
key: "readOnly",
getElement: (item: VolumeMountRow): ReactElement => {
return (
<StatusBadge
text={item.readOnly === "true" ? "Read-Only" : "Read-Write"}
type={
item.readOnly === "true"
? StatusBadgeType.Warning
: StatusBadgeType.Neutral
}
/>
);
},
},
];
return (
<div className="space-y-4">
{/* Search bar */}
<div className="px-4 pt-4">
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Icon
icon={IconProp.Search}
className="h-4 w-4 text-gray-400"
/>
</div>
<input
type="text"
placeholder="Search by volume name or mount path..."
value={search}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
}}
className="block w-full rounded-md border border-gray-300 bg-white py-2 pl-9 pr-20 text-sm placeholder-gray-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-2">
{search && (
<span className="text-xs text-gray-400 tabular-nums">
{totalMatchCount}/{totalMountCount}
</span>
)}
{search && (
<button
onClick={() => {
setSearch("");
}}
className="rounded p-0.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
>
<Icon icon={IconProp.Close} className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
</div>
{allContainers.map(
(container: KubernetesContainerSpec, containerIdx: number) => {
if (container.volumeMounts.length === 0) {
return null;
}
const filteredMounts: Array<{
name: string;
mountPath: string;
readOnly: boolean;
}> = search
? container.volumeMounts.filter(
(m: { name: string; mountPath: string; readOnly: boolean }) => {
return (
m.name.toLowerCase().includes(searchLower) ||
m.mountPath.toLowerCase().includes(searchLower)
);
},
)
: container.volumeMounts;
if (filteredMounts.length === 0) {
return null;
}
const isInit: boolean = containerIdx < props.initContainers.length;
const tableData: Array<VolumeMountRow> = filteredMounts.map(
(mount: {
name: string;
mountPath: string;
readOnly: boolean;
}): VolumeMountRow => {
return {
name: mount.name,
mountPath: mount.mountPath,
readOnly: String(mount.readOnly),
};
},
);
return (
<Card
key={containerIdx}
title={`${isInit ? "Init Container: " : ""}${container.name}`}
description={`${filteredMounts.length} volume mount${filteredMounts.length !== 1 ? "s" : ""}`}
>
<LocalTable
id={`volume-mounts-${containerIdx}`}
data={tableData}
columns={columns}
singularLabel="Mount"
pluralLabel="Mounts"
/>
</Card>
);
},
)}
</div>
);
};
export default KubernetesVolumeMountsTab;

View File

@@ -0,0 +1,294 @@
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import Card from "Common/UI/Components/Card/Card";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { fetchRawK8sObject } from "../../Pages/Kubernetes/Utils/KubernetesObjectFetcher";
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
import IconProp from "Common/Types/Icon/IconProp";
export interface ComponentProps {
clusterIdentifier: string;
resourceType: string;
resourceName: string;
namespace?: string | undefined;
}
/**
* Convert a JavaScript object to YAML string.
*/
function toYaml(obj: unknown, indent: number = 0): string {
const prefix: string = " ".repeat(indent);
if (obj === null || obj === undefined) {
return "null";
}
if (typeof obj === "string") {
// Quote strings that contain special chars or look like numbers
if (
obj.includes(":") ||
obj.includes("#") ||
obj.includes("\n") ||
obj.includes("'") ||
obj.includes('"') ||
obj === "" ||
obj === "true" ||
obj === "false" ||
obj === "null" ||
/^\d/.test(obj)
) {
return `"${obj.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
return obj;
}
if (typeof obj === "number" || typeof obj === "boolean") {
return String(obj);
}
if (Array.isArray(obj)) {
if (obj.length === 0) {
return "[]";
}
const lines: Array<string> = [];
for (const item of obj) {
if (typeof item === "object" && item !== null && !Array.isArray(item)) {
const entries: Array<[string, unknown]> = Object.entries(
item as Record<string, unknown>,
);
if (entries.length > 0) {
const [firstKey, firstVal] = entries[0]!;
lines.push(
`${prefix}- ${firstKey}: ${toYaml(firstVal, indent + 2)}`,
);
for (let i: number = 1; i < entries.length; i++) {
const [key, val] = entries[i]!;
lines.push(
`${prefix} ${key}: ${toYaml(val, indent + 2)}`,
);
}
} else {
lines.push(`${prefix}- {}`);
}
} else {
lines.push(`${prefix}- ${toYaml(item, indent + 1)}`);
}
}
return "\n" + lines.join("\n");
}
if (typeof obj === "object") {
const record: Record<string, unknown> = obj as Record<string, unknown>;
const keys: Array<string> = Object.keys(record);
if (keys.length === 0) {
return "{}";
}
const lines: Array<string> = [];
for (const key of keys) {
const val: unknown = record[key];
if (
val !== null &&
val !== undefined &&
typeof val === "object" &&
!Array.isArray(val) &&
Object.keys(val as Record<string, unknown>).length > 0
) {
lines.push(`${prefix}${key}:`);
lines.push(toYaml(val, indent + 1));
} else if (Array.isArray(val) && val.length > 0) {
lines.push(`${prefix}${key}:${toYaml(val, indent + 1)}`);
} else {
lines.push(`${prefix}${key}: ${toYaml(val, indent + 1)}`);
}
}
return lines.join("\n");
}
return String(obj);
}
const KubernetesYamlTab: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [yamlContent, setYamlContent] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [copied, setCopied] = useState<boolean>(false);
useEffect(() => {
const fetchData: () => Promise<void> = async (): Promise<void> => {
setIsLoading(true);
setError("");
try {
const result: Record<string, unknown> | null =
await fetchRawK8sObject({
clusterIdentifier: props.clusterIdentifier,
resourceType: props.resourceType,
resourceName: props.resourceName,
namespace: props.namespace,
});
if (result && Object.keys(result).length > 0) {
const yaml: string = toYaml(result);
setYamlContent(yaml);
} else {
setYamlContent("");
}
} catch {
setError("Failed to fetch resource data.");
} finally {
setIsLoading(false);
}
};
fetchData();
}, [
props.clusterIdentifier,
props.resourceType,
props.resourceName,
props.namespace,
]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (!yamlContent) {
return (
<ErrorMessage message="No resource spec data available. Ensure the kubernetes-agent has resourceSpecs.enabled set to true in the Helm values." />
);
}
const lines: Array<string> = yamlContent.split("\n");
/**
* Simple YAML syntax highlighter.
* Returns an array of React elements with colored spans for keys, values, etc.
*/
const highlightYamlLine = (line: string): ReactElement => {
// Empty or whitespace-only line
if (line.trim() === "") {
return <span>{line}</span>;
}
// Comment lines
if (line.trimStart().startsWith("#")) {
return <span className="text-gray-400 italic">{line}</span>;
}
// Array item prefix " - "
const arrayMatch: RegExpMatchArray | null = line.match(
/^(\s*)(- )(.*)$/,
);
if (arrayMatch) {
const [, indent, dash, rest] = arrayMatch;
// Check if rest has a key: value pattern
const kvMatch: RegExpMatchArray | null = (rest || "").match(
/^([^:]+):\s*(.*)$/,
);
if (kvMatch) {
const [, key, val] = kvMatch;
return (
<span>
{indent}
<span className="text-gray-500">{dash}</span>
<span className="text-indigo-700 font-medium">{key}</span>
<span className="text-gray-500">: </span>
<span className="text-emerald-700">{val}</span>
</span>
);
}
return (
<span>
{indent}
<span className="text-gray-500">{dash}</span>
<span className="text-emerald-700">{rest}</span>
</span>
);
}
// Key: value lines
const kvLineMatch: RegExpMatchArray | null = line.match(
/^(\s*)([^:]+):\s*(.+)$/,
);
if (kvLineMatch) {
const [, indent, key, val] = kvLineMatch;
return (
<span>
{indent}
<span className="text-indigo-700 font-medium">{key}</span>
<span className="text-gray-500">: </span>
<span className="text-emerald-700">{val}</span>
</span>
);
}
// Key-only lines (e.g., "metadata:")
const keyOnlyMatch: RegExpMatchArray | null = line.match(
/^(\s*)([^:]+):(\s*)$/,
);
if (keyOnlyMatch) {
const [, indent, key] = keyOnlyMatch;
return (
<span>
{indent}
<span className="text-indigo-700 font-medium">{key}</span>
<span className="text-gray-500">:</span>
</span>
);
}
// Fallback
return <span className="text-gray-800">{line}</span>;
};
return (
<Card
title="Resource Specification"
description="Full resource specification as collected by the kubernetes-agent."
buttons={[
{
title: copied ? "Copied!" : "Copy",
buttonStyle: ButtonStyleType.NORMAL,
icon: IconProp.Copy,
onClick: () => {
navigator.clipboard.writeText(yamlContent);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
},
},
]}
>
<div className="overflow-auto max-h-[600px] bg-gray-50 rounded-lg border border-gray-200">
<table className="w-full">
<tbody>
{lines.map((line: string, index: number) => {
return (
<tr key={index} className="hover:bg-gray-100/50">
<td className="px-4 py-0 text-right text-xs text-gray-400 select-none w-12 align-top font-mono border-r border-gray-200">
{index + 1}
</td>
<td className="px-4 py-0 text-sm font-mono whitespace-pre">
{highlightYamlLine(line)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
);
};
export default KubernetesYamlTab;

View File

@@ -197,6 +197,10 @@ const MetricCharts: FunctionComponent<ComponentProps> = (
options: {
type: YAxisType.Number,
formatter: (value: number) => {
if (queryConfig.yAxisValueFormatter) {
return queryConfig.yAxisValueFormatter(value);
}
const metricType: MetricType | undefined =
props.metricTypes.find((m: MetricType) => {
return (

View File

@@ -11,6 +11,7 @@ import {
extractObjectFromLogBody,
getKvStringValue,
getKvValue,
kvListToPlainObject,
KubernetesPodObject,
KubernetesNodeObject,
KubernetesDeploymentObject,
@@ -19,6 +20,10 @@ import {
KubernetesJobObject,
KubernetesCronJobObject,
KubernetesNamespaceObject,
KubernetesPVCObject,
KubernetesPVObject,
KubernetesHPAObject,
KubernetesVPAObject,
parsePodObject,
parseNodeObject,
parseDeploymentObject,
@@ -27,6 +32,10 @@ import {
parseJobObject,
parseCronJobObject,
parseNamespaceObject,
parsePVCObject,
parsePVObject,
parseHPAObject,
parseVPAObject,
} from "./KubernetesObjectParser";
export type KubernetesObjectType =
@@ -37,7 +46,11 @@ export type KubernetesObjectType =
| KubernetesDaemonSetObject
| KubernetesJobObject
| KubernetesCronJobObject
| KubernetesNamespaceObject;
| KubernetesNamespaceObject
| KubernetesPVCObject
| KubernetesPVObject
| KubernetesHPAObject
| KubernetesVPAObject;
export interface FetchK8sObjectOptions {
clusterIdentifier: string;
@@ -58,6 +71,10 @@ function getParser(resourceType: string): ParserFunction | null {
jobs: parseJobObject,
cronjobs: parseCronJobObject,
namespaces: parseNamespaceObject,
persistentvolumeclaims: parsePVCObject,
persistentvolumes: parsePVObject,
horizontalpodautoscalers: parseHPAObject,
verticalpodautoscalers: parseVPAObject,
};
return parsers[resourceType] || null;
}
@@ -91,7 +108,6 @@ export async function fetchLatestK8sObject<T extends KubernetesObjectType>(
projectId: projectId,
time: new InBetween<Date>(startDate, endDate),
attributes: {
"logAttributes.event.domain": "k8s",
"logAttributes.k8s.resource.name": options.resourceType,
},
},
@@ -166,6 +182,198 @@ export async function fetchLatestK8sObject<T extends KubernetesObjectType>(
}
}
/**
* Fetch the raw K8s resource object (as a plain JS object, not parsed into typed interfaces).
* This preserves the complete original K8s manifest for YAML display.
*/
export async function fetchRawK8sObject(
options: FetchK8sObjectOptions,
): Promise<Record<string, unknown> | null> {
const projectId: string | undefined =
ProjectUtil.getCurrentProjectId()?.toString();
if (!projectId) {
return null;
}
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -24);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const queryOptions: any = {
modelType: Log,
query: {
projectId: projectId,
time: new InBetween<Date>(startDate, endDate),
attributes: {
"logAttributes.k8s.resource.name": options.resourceType,
},
},
limit: 500,
skip: 0,
select: {
time: true,
body: true,
attributes: true,
},
sort: {
time: SortOrder.Descending,
},
requestOptions: {},
};
const listResult: ListResult<Log> =
await AnalyticsModelAPI.getList<Log>(queryOptions);
for (const log of listResult.data) {
const attrs: JSONObject = log.attributes || {};
if (
attrs["resource.k8s.cluster.name"] !== options.clusterIdentifier &&
attrs["k8s.cluster.name"] !== options.clusterIdentifier
) {
continue;
}
if (typeof log.body !== "string") {
continue;
}
const objectKvList: JSONObject | null = extractObjectFromLogBody(
log.body,
);
if (!objectKvList) {
continue;
}
const metadataKv: string | JSONObject | null = getKvValue(
objectKvList,
"metadata",
);
if (!metadataKv || typeof metadataKv === "string") {
continue;
}
const name: string = getKvStringValue(metadataKv, "name");
const namespace: string = getKvStringValue(metadataKv, "namespace");
if (name !== options.resourceName) {
continue;
}
if (options.namespace && namespace && namespace !== options.namespace) {
continue;
}
// Convert the raw OTLP kvList to a plain JS object
return kvListToPlainObject(objectKvList);
}
return null;
} catch {
return null;
}
}
/**
* Batch fetch all K8s objects of a given type for a cluster.
* Returns a Map keyed by "namespace/name" (or just "name" for cluster-scoped resources).
*/
export async function fetchK8sObjectsBatch(options: {
clusterIdentifier: string;
resourceType: string;
}): Promise<Map<string, KubernetesObjectType>> {
const parser: ParserFunction | null = getParser(options.resourceType);
if (!parser) {
return new Map();
}
const projectId: string | undefined =
ProjectUtil.getCurrentProjectId()?.toString();
if (!projectId) {
return new Map();
}
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -24);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const queryOptions: any = {
modelType: Log,
query: {
projectId: projectId,
time: new InBetween<Date>(startDate, endDate),
attributes: {
"logAttributes.k8s.resource.name": options.resourceType,
},
},
limit: 2000,
skip: 0,
select: {
time: true,
body: true,
attributes: true,
},
sort: {
time: SortOrder.Descending,
},
requestOptions: {},
};
const listResult: ListResult<Log> =
await AnalyticsModelAPI.getList<Log>(queryOptions);
const resultMap: Map<string, KubernetesObjectType> = new Map();
for (const log of listResult.data) {
const attrs: JSONObject = log.attributes || {};
if (
attrs["resource.k8s.cluster.name"] !== options.clusterIdentifier &&
attrs["k8s.cluster.name"] !== options.clusterIdentifier
) {
continue;
}
if (typeof log.body !== "string") {
continue;
}
const objectKvList: JSONObject | null = extractObjectFromLogBody(
log.body,
);
if (!objectKvList) {
continue;
}
const metadataKv: string | JSONObject | null = getKvValue(
objectKvList,
"metadata",
);
if (!metadataKv || typeof metadataKv === "string") {
continue;
}
const name: string = getKvStringValue(metadataKv, "name");
const namespace: string = getKvStringValue(metadataKv, "namespace");
const key: string = namespace ? `${namespace}/${name}` : name;
// Only keep the latest (first encountered since sorted desc)
if (resultMap.has(key)) {
continue;
}
const parsed: KubernetesObjectType | null = parser(objectKvList);
if (parsed) {
resultMap.set(key, parsed);
}
}
return resultMap;
} catch {
return new Map();
}
}
/**
* Fetch K8s events related to a specific resource.
*/
@@ -319,6 +527,147 @@ export async function fetchK8sEventsForResource(options: {
}
}
/**
* Fetch recent warning events for an entire cluster.
*/
export async function fetchClusterWarningEvents(options: {
clusterIdentifier: string;
limit?: number | undefined;
}): Promise<Array<KubernetesEvent>> {
const projectId: string | undefined =
ProjectUtil.getCurrentProjectId()?.toString();
if (!projectId) {
return [];
}
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -24);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const eventsQueryOptions: any = {
modelType: Log,
query: {
projectId: projectId,
time: new InBetween<Date>(startDate, endDate),
attributes: {
"logAttributes.event.domain": "k8s",
"logAttributes.k8s.resource.name": "events",
},
},
limit: 500,
skip: 0,
select: {
time: true,
body: true,
attributes: true,
},
sort: {
time: SortOrder.Descending,
},
requestOptions: {},
};
const listResult: ListResult<Log> =
await AnalyticsModelAPI.getList<Log>(eventsQueryOptions);
const events: Array<KubernetesEvent> = [];
const maxEvents: number = options.limit || 10;
for (const log of listResult.data) {
if (events.length >= maxEvents) {
break;
}
const attrs: JSONObject = log.attributes || {};
if (
attrs["resource.k8s.cluster.name"] !== options.clusterIdentifier &&
attrs["k8s.cluster.name"] !== options.clusterIdentifier
) {
continue;
}
if (typeof log.body !== "string") {
continue;
}
let bodyObj: JSONObject | null = null;
try {
bodyObj = JSON.parse(log.body) as JSONObject;
} catch {
continue;
}
const topKvList: JSONObject | undefined = bodyObj["kvlistValue"] as
| JSONObject
| undefined;
if (!topKvList) {
continue;
}
const objectVal: string | JSONObject | null = getKvValue(
topKvList,
"object",
);
if (!objectVal || typeof objectVal === "string") {
continue;
}
const objectKvList: JSONObject = objectVal;
const eventType: string =
getKvStringValue(objectKvList, "type") || "";
// Only include Warning events
if (eventType !== "Warning") {
continue;
}
const reason: string =
getKvStringValue(objectKvList, "reason") || "";
const note: string =
getKvStringValue(objectKvList, "note") || "";
const regardingKind: string =
getKvStringValue(
getKvValue(objectKvList, "regarding") as
| JSONObject
| undefined,
"kind",
) || "";
const regardingName: string =
getKvStringValue(
getKvValue(objectKvList, "regarding") as
| JSONObject
| undefined,
"name",
) || "";
const regardingNamespace: string =
getKvStringValue(
getKvValue(objectKvList, "regarding") as
| JSONObject
| undefined,
"namespace",
) || "";
events.push({
timestamp: log.time
? OneUptimeDate.getDateAsLocalFormattedString(log.time)
: "",
type: eventType,
reason: reason || "Unknown",
objectKind: regardingKind || "Unknown",
objectName: regardingName || "Unknown",
namespace: regardingNamespace || "default",
message: note || "",
});
}
return events;
} catch {
return [];
}
}
/**
* Fetch application logs for a pod/container from the Log table.
* These come from the filelog receiver (not k8sobjects).

View File

@@ -143,6 +143,81 @@ export function getArrayValues(
.filter(Boolean) as Array<JSONObject>;
}
/**
* Recursively convert an OTLP value wrapper to a plain JavaScript value.
* Handles stringValue, intValue, boolValue, kvlistValue, and arrayValue.
*/
function convertOtlpValue(
valueWrapper: JSONObject,
): unknown {
if (valueWrapper["stringValue"] !== undefined) {
return valueWrapper["stringValue"];
}
if (valueWrapper["intValue"] !== undefined) {
return Number(valueWrapper["intValue"]);
}
if (valueWrapper["boolValue"] !== undefined) {
return valueWrapper["boolValue"];
}
if (valueWrapper["doubleValue"] !== undefined) {
return Number(valueWrapper["doubleValue"]);
}
if (valueWrapper["kvlistValue"]) {
return kvListToPlainObject(valueWrapper["kvlistValue"] as JSONObject);
}
if (valueWrapper["arrayValue"]) {
return convertOtlpArray(valueWrapper["arrayValue"] as JSONObject);
}
return null;
}
/**
* Convert an OTLP arrayValue to a plain JavaScript array.
*/
function convertOtlpArray(
arrayValue: JSONObject,
): Array<unknown> {
const values: Array<JSONObject> | undefined = arrayValue["values"] as
| Array<JSONObject>
| undefined;
if (!values) {
return [];
}
return values.map((item: JSONObject) => {
// Each item in arrayValue.values is a value wrapper
return convertOtlpValue(item);
});
}
/**
* Convert an OTLP kvlistValue (nested key-value structure) to a plain
* JavaScript object. This preserves the full original K8s manifest structure.
*/
export function kvListToPlainObject(
kvList: JSONObject | undefined,
): Record<string, unknown> {
const result: Record<string, unknown> = {};
if (!kvList) {
return result;
}
const values: Array<JSONObject> | undefined = kvList["values"] as
| Array<JSONObject>
| undefined;
if (!values) {
return result;
}
for (const entry of values) {
const key: string = (entry["key"] as string) || "";
const val: JSONObject | undefined = entry["value"] as
| JSONObject
| undefined;
if (key && val) {
result[key] = convertOtlpValue(val);
}
}
return result;
}
/*
* ============================================================
* TypeScript interfaces for parsed K8s objects
@@ -205,6 +280,7 @@ export interface KubernetesContainerStatus {
ready: boolean;
restartCount: number;
state: string;
reason: string;
image: string;
}
@@ -232,6 +308,7 @@ export interface KubernetesPodObject {
phase: string;
podIP: string;
hostIP: string;
qosClass: string;
conditions: Array<KubernetesCondition>;
containerStatuses: Array<KubernetesContainerStatus>;
initContainerStatuses: Array<KubernetesContainerStatus>;
@@ -340,6 +417,104 @@ export interface KubernetesNamespaceObject {
};
}
export interface KubernetesPVCObject {
metadata: KubernetesObjectMetadata;
spec: {
accessModes: Array<string>;
storageClassName: string;
volumeName: string;
resources: {
requests: {
storage: string;
};
};
};
status: {
phase: string; // Bound, Pending, Lost
capacity: {
storage: string;
};
};
}
export interface KubernetesPVObject {
metadata: KubernetesObjectMetadata;
spec: {
capacity: {
storage: string;
};
accessModes: Array<string>;
storageClassName: string;
persistentVolumeReclaimPolicy: string;
claimRef: {
name: string;
namespace: string;
};
};
status: {
phase: string; // Available, Bound, Released, Failed
};
}
export interface KubernetesHPAMetricSpec {
type: string;
resourceName: string;
targetType: string;
targetValue: string;
}
export interface KubernetesHPACondition {
type: string;
status: string;
reason: string;
message: string;
lastTransitionTime: string;
}
export interface KubernetesHPAObject {
metadata: KubernetesObjectMetadata;
spec: {
minReplicas: number;
maxReplicas: number;
scaleTargetRef: {
kind: string;
name: string;
};
metrics: Array<KubernetesHPAMetricSpec>;
};
status: {
currentReplicas: number;
desiredReplicas: number;
conditions: Array<KubernetesHPACondition>;
};
}
export interface KubernetesVPAContainerRecommendation {
containerName: string;
target: Record<string, string>;
lowerBound: Record<string, string>;
upperBound: Record<string, string>;
}
export interface KubernetesVPAObject {
metadata: KubernetesObjectMetadata;
spec: {
targetRef: {
kind: string;
name: string;
};
updatePolicy: {
updateMode: string;
};
resourcePolicy: string;
};
status: {
recommendation: {
containerRecommendations: Array<KubernetesVPAContainerRecommendation>;
};
};
}
/*
* ============================================================
* Parsers
@@ -583,12 +758,23 @@ function parseContainerStatuses(
// state is a kvlist with one key (running/waiting/terminated)
const stateKv: string | JSONObject | null = getKvValue(kvList, "state");
let state: string = "Unknown";
let reason: string = "";
if (stateKv && typeof stateKv !== "string") {
const stateValues: Array<JSONObject> | undefined = stateKv["values"] as
| Array<JSONObject>
| undefined;
if (stateValues && stateValues.length > 0 && stateValues[0]) {
state = (stateValues[0]["key"] as string) || "Unknown";
// Extract reason from the state's nested kvlist value (e.g., waiting -> { reason: "CrashLoopBackOff" })
const stateDetail: JSONObject | undefined = stateValues[0]["value"] as
| JSONObject
| undefined;
if (stateDetail && stateDetail["kvlistValue"]) {
reason = getKvStringValue(
stateDetail["kvlistValue"] as JSONObject,
"reason",
);
}
}
}
@@ -597,6 +783,7 @@ function parseContainerStatuses(
ready: getKvStringValue(kvList, "ready") === "true",
restartCount: parseInt(getKvStringValue(kvList, "restartCount")) || 0,
state,
reason,
image: getKvStringValue(kvList, "image"),
};
});
@@ -740,6 +927,7 @@ export function parsePodObject(
let phase: string = "";
let podIP: string = "";
let hostIP: string = "";
let qosClass: string = "";
let conditions: Array<KubernetesCondition> = [];
let containerStatuses: Array<KubernetesContainerStatus> = [];
let initContainerStatuses: Array<KubernetesContainerStatus> = [];
@@ -748,6 +936,7 @@ export function parsePodObject(
phase = getKvStringValue(statusKv, "phase");
podIP = getKvStringValue(statusKv, "podIP");
hostIP = getKvStringValue(statusKv, "hostIP");
qosClass = getKvStringValue(statusKv, "qosClass");
const condArray: string | JSONObject | null = getKvValue(
statusKv,
@@ -789,6 +978,7 @@ export function parsePodObject(
phase,
podIP,
hostIP,
qosClass,
conditions,
containerStatuses,
initContainerStatuses,
@@ -1261,6 +1451,412 @@ export function parseNamespaceObject(
}
}
export function parsePVCObject(
objectKvList: JSONObject,
): KubernetesPVCObject | null {
try {
const metadataKv: string | JSONObject | null = getKvValue(
objectKvList,
"metadata",
);
if (!metadataKv || typeof metadataKv === "string") {
return null;
}
const metadata: KubernetesObjectMetadata = parseMetadata(metadataKv);
if (!metadata.name) {
return null;
}
const specKv: string | JSONObject | null = getKvValue(objectKvList, "spec");
const statusKv: string | JSONObject | null = getKvValue(
objectKvList,
"status",
);
// Parse spec
let accessModes: Array<string> = [];
let storageClassName: string = "";
let volumeName: string = "";
let requestsStorage: string = "";
if (specKv && typeof specKv !== "string") {
storageClassName = getKvStringValue(specKv, "storageClassName");
volumeName = getKvStringValue(specKv, "volumeName");
const accessModesArray: string | JSONObject | null = getKvValue(
specKv,
"accessModes",
);
if (accessModesArray && typeof accessModesArray !== "string") {
const modeValues: Array<JSONObject> =
(accessModesArray["values"] as Array<JSONObject>) || [];
for (const v of modeValues) {
if (v["stringValue"]) {
accessModes.push(v["stringValue"] as string);
}
}
}
const resourcesKv: string | JSONObject | null = getKvValue(
specKv,
"resources",
);
if (resourcesKv && typeof resourcesKv !== "string") {
requestsStorage = getNestedKvValue(resourcesKv, "requests", "storage");
}
}
// Parse status
let phase: string = "";
let capacityStorage: string = "";
if (statusKv && typeof statusKv !== "string") {
phase = getKvStringValue(statusKv, "phase");
capacityStorage = getNestedKvValue(statusKv, "capacity", "storage");
}
return {
metadata,
spec: {
accessModes,
storageClassName,
volumeName,
resources: {
requests: {
storage: requestsStorage,
},
},
},
status: {
phase,
capacity: {
storage: capacityStorage,
},
},
};
} catch {
return null;
}
}
export function parsePVObject(
objectKvList: JSONObject,
): KubernetesPVObject | null {
try {
const metadataKv: string | JSONObject | null = getKvValue(
objectKvList,
"metadata",
);
if (!metadataKv || typeof metadataKv === "string") {
return null;
}
const metadata: KubernetesObjectMetadata = parseMetadata(metadataKv);
if (!metadata.name) {
return null;
}
const specKv: string | JSONObject | null = getKvValue(objectKvList, "spec");
const statusKv: string | JSONObject | null = getKvValue(
objectKvList,
"status",
);
// Parse spec
let capacityStorage: string = "";
let accessModes: Array<string> = [];
let storageClassName: string = "";
let persistentVolumeReclaimPolicy: string = "";
let claimRefName: string = "";
let claimRefNamespace: string = "";
if (specKv && typeof specKv !== "string") {
capacityStorage = getNestedKvValue(specKv, "capacity", "storage");
storageClassName = getKvStringValue(specKv, "storageClassName");
persistentVolumeReclaimPolicy = getKvStringValue(
specKv,
"persistentVolumeReclaimPolicy",
);
const accessModesArray: string | JSONObject | null = getKvValue(
specKv,
"accessModes",
);
if (accessModesArray && typeof accessModesArray !== "string") {
const modeValues: Array<JSONObject> =
(accessModesArray["values"] as Array<JSONObject>) || [];
for (const v of modeValues) {
if (v["stringValue"]) {
accessModes.push(v["stringValue"] as string);
}
}
}
const claimRefKv: string | JSONObject | null = getKvValue(
specKv,
"claimRef",
);
if (claimRefKv && typeof claimRefKv !== "string") {
claimRefName = getKvStringValue(claimRefKv, "name");
claimRefNamespace = getKvStringValue(claimRefKv, "namespace");
}
}
// Parse status
let phase: string = "";
if (statusKv && typeof statusKv !== "string") {
phase = getKvStringValue(statusKv, "phase");
}
return {
metadata,
spec: {
capacity: {
storage: capacityStorage,
},
accessModes,
storageClassName,
persistentVolumeReclaimPolicy,
claimRef: {
name: claimRefName,
namespace: claimRefNamespace,
},
},
status: {
phase,
},
};
} catch {
return null;
}
}
export function parseHPAObject(
objectKvList: JSONObject,
): KubernetesHPAObject | null {
try {
const metadataKv: string | JSONObject | null = getKvValue(
objectKvList,
"metadata",
);
if (!metadataKv || typeof metadataKv === "string") {
return null;
}
const specKv: string | JSONObject | null = getKvValue(objectKvList, "spec");
const statusKv: string | JSONObject | null = getKvValue(
objectKvList,
"status",
);
let minReplicas: number = 0;
let maxReplicas: number = 0;
let scaleTargetRef: { kind: string; name: string } = {
kind: "",
name: "",
};
const metrics: Array<KubernetesHPAMetricSpec> = [];
if (specKv && typeof specKv !== "string") {
minReplicas = parseInt(getKvStringValue(specKv, "minReplicas")) || 0;
maxReplicas = parseInt(getKvStringValue(specKv, "maxReplicas")) || 0;
const targetRefKv: string | JSONObject | null = getKvValue(
specKv,
"scaleTargetRef",
);
if (targetRefKv && typeof targetRefKv !== "string") {
scaleTargetRef = {
kind: getKvStringValue(targetRefKv, "kind"),
name: getKvStringValue(targetRefKv, "name"),
};
}
const metricsArrayKv: string | JSONObject | null = getKvValue(
specKv,
"metrics",
);
if (metricsArrayKv && typeof metricsArrayKv !== "string") {
const metricsItems: Array<JSONObject> = getArrayValues(metricsArrayKv);
for (const metricKv of metricsItems) {
const metricType: string = getKvStringValue(metricKv, "type");
let resourceName: string = "";
let targetType: string = "";
let targetValue: string = "";
const resourceKv: string | JSONObject | null = getKvValue(
metricKv,
"resource",
);
if (resourceKv && typeof resourceKv !== "string") {
resourceName = getKvStringValue(resourceKv, "name");
const targetKv: string | JSONObject | null = getKvValue(
resourceKv,
"target",
);
if (targetKv && typeof targetKv !== "string") {
targetType = getKvStringValue(targetKv, "type");
targetValue =
getKvStringValue(targetKv, "averageUtilization") ||
getKvStringValue(targetKv, "averageValue") ||
getKvStringValue(targetKv, "value");
}
}
metrics.push({
type: metricType,
resourceName,
targetType,
targetValue,
});
}
}
}
let currentReplicas: number = 0;
let desiredReplicas: number = 0;
let conditions: Array<KubernetesHPACondition> = [];
if (statusKv && typeof statusKv !== "string") {
currentReplicas =
parseInt(getKvStringValue(statusKv, "currentReplicas")) || 0;
desiredReplicas =
parseInt(getKvStringValue(statusKv, "desiredReplicas")) || 0;
const condArray: string | JSONObject | null = getKvValue(
statusKv,
"conditions",
);
if (condArray && typeof condArray !== "string") {
const condItems: Array<JSONObject> = getArrayValues(condArray);
conditions = condItems.map(
(condKv: JSONObject): KubernetesHPACondition => {
return {
type: getKvStringValue(condKv, "type"),
status: getKvStringValue(condKv, "status"),
reason: getKvStringValue(condKv, "reason"),
message: getKvStringValue(condKv, "message"),
lastTransitionTime: getKvStringValue(
condKv,
"lastTransitionTime",
),
};
},
);
}
}
return {
metadata: parseMetadata(metadataKv),
spec: { minReplicas, maxReplicas, scaleTargetRef, metrics },
status: { currentReplicas, desiredReplicas, conditions },
};
} catch {
return null;
}
}
export function parseVPAObject(
objectKvList: JSONObject,
): KubernetesVPAObject | null {
try {
const metadataKv: string | JSONObject | null = getKvValue(
objectKvList,
"metadata",
);
if (!metadataKv || typeof metadataKv === "string") {
return null;
}
const specKv: string | JSONObject | null = getKvValue(objectKvList, "spec");
const statusKv: string | JSONObject | null = getKvValue(
objectKvList,
"status",
);
let targetRef: { kind: string; name: string } = { kind: "", name: "" };
let updatePolicy: { updateMode: string } = { updateMode: "" };
let resourcePolicy: string = "";
if (specKv && typeof specKv !== "string") {
const targetRefKv: string | JSONObject | null = getKvValue(
specKv,
"targetRef",
);
if (targetRefKv && typeof targetRefKv !== "string") {
targetRef = {
kind: getKvStringValue(targetRefKv, "kind"),
name: getKvStringValue(targetRefKv, "name"),
};
}
const updatePolicyKv: string | JSONObject | null = getKvValue(
specKv,
"updatePolicy",
);
if (updatePolicyKv && typeof updatePolicyKv !== "string") {
updatePolicy = {
updateMode: getKvStringValue(updatePolicyKv, "updateMode"),
};
}
resourcePolicy = getKvStringValue(specKv, "resourcePolicy");
}
const containerRecommendations: Array<KubernetesVPAContainerRecommendation> =
[];
if (statusKv && typeof statusKv !== "string") {
const recommendationKv: string | JSONObject | null = getKvValue(
statusKv,
"recommendation",
);
if (recommendationKv && typeof recommendationKv !== "string") {
const containerRecsArrayKv: string | JSONObject | null = getKvValue(
recommendationKv,
"containerRecommendations",
);
if (
containerRecsArrayKv &&
typeof containerRecsArrayKv !== "string"
) {
const recItems: Array<JSONObject> =
getArrayValues(containerRecsArrayKv);
for (const recKv of recItems) {
const targetKv: string | JSONObject | null = getKvValue(
recKv,
"target",
);
const lowerBoundKv: string | JSONObject | null = getKvValue(
recKv,
"lowerBound",
);
const upperBoundKv: string | JSONObject | null = getKvValue(
recKv,
"upperBound",
);
containerRecommendations.push({
containerName: getKvStringValue(recKv, "containerName"),
target:
targetKv && typeof targetKv !== "string"
? getKvListAsRecord(targetKv)
: {},
lowerBound:
lowerBoundKv && typeof lowerBoundKv !== "string"
? getKvListAsRecord(lowerBoundKv)
: {},
upperBound:
upperBoundKv && typeof upperBoundKv !== "string"
? getKvListAsRecord(upperBoundKv)
: {},
});
}
}
}
}
return {
metadata: parseMetadata(metadataKv),
spec: { targetRef, updatePolicy, resourcePolicy },
status: {
recommendation: { containerRecommendations },
},
};
} catch {
return null;
}
}
/**
* Extract the K8s object from a raw OTLP log body string.
* For k8sobjects pull mode, the body is:

View File

@@ -13,6 +13,9 @@ export interface KubernetesResource {
namespace: string;
cpuUtilization: number | null;
memoryUsageBytes: number | null;
memoryLimitBytes: number | null;
status: string;
age: string;
additionalAttributes: Record<string, string>;
}
@@ -98,6 +101,9 @@ export default class KubernetesResourceUtils {
namespace: namespace,
cpuUtilization: dataPoint.value ?? null,
memoryUsageBytes: null,
memoryLimitBytes: null,
status: "",
age: "",
additionalAttributes: additionalAttrs,
});
}
@@ -184,6 +190,34 @@ export default class KubernetesResourceUtils {
return resources;
}
public static formatAge(creationTimestamp: string | undefined): string {
if (!creationTimestamp) {
return "N/A";
}
const created: Date = new Date(creationTimestamp);
const now: Date = new Date();
const diffMs: number = now.getTime() - created.getTime();
const diffSec: number = Math.floor(diffMs / 1000);
if (diffSec < 60) {
return `${diffSec}s`;
}
const diffMin: number = Math.floor(diffSec / 60);
if (diffMin < 60) {
return `${diffMin}m`;
}
const diffHours: number = Math.floor(diffMin / 60);
if (diffHours < 24) {
return `${diffHours}h`;
}
const diffDays: number = Math.floor(diffHours / 24);
if (diffDays < 30) {
return `${diffDays}d`;
}
const diffMonths: number = Math.floor(diffDays / 30);
return `${diffMonths}mo`;
}
public static formatCpuValue(value: number | null): string {
if (value === null || value === undefined) {
return "N/A";
@@ -210,4 +244,48 @@ export default class KubernetesResourceUtils {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
public static formatBytesForChart(value: number): string {
if (value === null || value === undefined) {
return "N/A";
}
const absValue: number = Math.abs(value);
if (absValue < 1024) {
return `${value.toFixed(0)} B`;
}
if (absValue < 1024 * 1024) {
return `${(value / 1024).toFixed(1)} KB`;
}
if (absValue < 1024 * 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(1)} MB`;
}
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
public static formatBytesPerSecForChart(value: number): string {
if (value === null || value === undefined) {
return "N/A";
}
const absValue: number = Math.abs(value);
if (absValue < 1024) {
return `${value.toFixed(0)} B/s`;
}
if (absValue < 1024 * 1024) {
return `${(value / 1024).toFixed(1)} KB/s`;
}
if (absValue < 1024 * 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(1)} MB/s`;
}
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB/s`;
}
}

View File

@@ -0,0 +1,209 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import {
getAllKubernetesAlertTemplates,
KubernetesAlertTemplate,
KubernetesAlertTemplateCategory,
} from "Common/Types/Monitor/KubernetesAlertTemplates";
import IconProp from "Common/Types/Icon/IconProp";
import Icon from "Common/UI/Components/Icon/Icon";
import Button, { ButtonStyleType } from "Common/UI/Components/Button/Button";
import RouteMap from "../../../Utils/RouteMap";
import PageMap from "../../../Utils/PageMap";
import Route from "Common/Types/API/Route";
const KubernetesClusterAlerts: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [cluster, setCluster] = useState<KubernetesCluster | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const fetchCluster: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const item: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
name: true,
},
});
setCluster(item);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchCluster().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (!cluster) {
return <ErrorMessage message="Cluster not found" />;
}
const allTemplates: Array<KubernetesAlertTemplate> =
getAllKubernetesAlertTemplates();
const categories: Array<KubernetesAlertTemplateCategory> = [
"Workload",
"Node",
"ControlPlane",
"Storage",
"Scheduling",
];
const getCategoryIcon = (
category: KubernetesAlertTemplateCategory,
): IconProp => {
switch (category) {
case "Workload":
return IconProp.Cube;
case "Node":
return IconProp.Server;
case "ControlPlane":
return IconProp.Settings;
case "Storage":
return IconProp.Disc;
case "Scheduling":
return IconProp.Clock;
default:
return IconProp.Alert;
}
};
const getCategoryDescription = (
category: KubernetesAlertTemplateCategory,
): string => {
switch (category) {
case "Workload":
return "Monitor workload health including pod restarts, replica mismatches, and job failures.";
case "Node":
return "Monitor node health including CPU, memory, disk usage, and node readiness.";
case "ControlPlane":
return "Monitor Kubernetes control plane components including etcd, API server, and scheduler.";
case "Storage":
return "Monitor storage resources including disk usage and persistent volume claims.";
case "Scheduling":
return "Monitor pod scheduling including pending pods and scheduler backlog.";
default:
return "";
}
};
return (
<Fragment>
<div className="mb-6">
<p className="text-sm text-gray-500">
Pre-built alert templates for common Kubernetes failure patterns. Click
&quot;Create Monitor&quot; to set up monitoring for your cluster{" "}
<strong>{cluster.name || cluster.clusterIdentifier}</strong>.
</p>
</div>
{categories.map((category: KubernetesAlertTemplateCategory) => {
const categoryTemplates: Array<KubernetesAlertTemplate> =
allTemplates.filter(
(t: KubernetesAlertTemplate) => t.category === category,
);
if (categoryTemplates.length === 0) {
return null;
}
return (
<Card
key={category}
title={
<span className="flex items-center">
<Icon icon={getCategoryIcon(category)} className="mr-2 h-4 w-4" />
{category === "ControlPlane" ? "Control Plane" : category}
</span>
}
description={getCategoryDescription(category)}
>
<div className="divide-y divide-gray-200">
{categoryTemplates.map(
(template: KubernetesAlertTemplate) => {
return (
<div
key={template.id}
className="flex items-center justify-between py-3 px-1"
>
<div className="flex-1">
<div className="flex items-center">
<span className="text-sm font-medium text-gray-900">
{template.name}
</span>
<span
className={`ml-2 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
template.severity === "Critical"
? "bg-red-100 text-red-800"
: "bg-yellow-100 text-yellow-800"
}`}
>
{template.severity}
</span>
</div>
<p className="mt-1 text-xs text-gray-500">
{template.description}
</p>
</div>
<div className="ml-4">
<Button
title="Create Monitor"
buttonStyle={ButtonStyleType.OUTLINE}
icon={IconProp.Add}
onClick={() => {
const baseRoute: string =
RouteMap[PageMap.MONITOR_CREATE]?.toString() || "";
const queryParams: string = `?monitorType=Kubernetes&templateId=${template.id}&clusterId=${cluster.clusterIdentifier || ""}`;
Navigation.navigate(
new Route(baseRoute + queryParams),
);
}}
/>
</div>
</div>
);
},
)}
</div>
</Card>
);
})}
</Fragment>
);
};
export default KubernetesClusterAlerts;

View File

@@ -9,7 +9,6 @@ import MetricQueryConfigData, {
} from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
@@ -25,6 +24,7 @@ import Tabs from "Common/UI/Components/Tabs/Tabs";
import { Tab } from "Common/UI/Components/Tabs/Tab";
import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab";
import KubernetesLogsTab from "../../../Components/Kubernetes/KubernetesLogsTab";
import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils";
const KubernetesClusterContainerDetail: FunctionComponent<
PageComponentProps
@@ -115,7 +115,7 @@ const KubernetesClusterContainerDetail: FunctionComponent<
title: "Container Memory Usage",
description: `Memory usage for container ${containerName}`,
legend: "Memory",
legendUnit: "bytes",
legendUnit: "",
},
metricQueryData: {
filterData: {
@@ -132,6 +132,7 @@ const KubernetesClusterContainerDetail: FunctionComponent<
},
},
getSeries: getSeries,
yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart,
};
const tabs: Array<Tab> = [
@@ -177,18 +178,7 @@ const KubernetesClusterContainerDetail: FunctionComponent<
},
];
return (
<Fragment>
<div className="mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="Container" value={containerName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
</div>
</div>
<Tabs tabs={tabs} onTabChange={() => {}} />
</Fragment>
);
return <Tabs tabs={tabs} onTabChange={() => {}} />;
};
export default KubernetesClusterContainerDetail;

View File

@@ -6,13 +6,7 @@ import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesRe
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import React, { FunctionComponent, ReactElement, useEffect, useState } from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
@@ -21,6 +15,11 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
import {
fetchK8sObjectsBatch,
KubernetesObjectType,
} from "../Utils/KubernetesObjectFetcher";
import { KubernetesPodObject } from "../Utils/KubernetesObjectParser";
const KubernetesClusterContainers: FunctionComponent<
PageComponentProps
@@ -48,14 +47,58 @@ const KubernetesClusterContainers: FunctionComponent<
return;
}
const containerList: Array<KubernetesResource> =
await KubernetesResourceUtils.fetchResourceListWithMemory({
const [containerList, podObjects]: [
Array<KubernetesResource>,
Map<string, KubernetesObjectType>,
] = await Promise.all([
KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: cluster.clusterIdentifier,
metricName: "container.cpu.utilization",
memoryMetricName: "container.memory.usage",
resourceNameAttribute: "resource.k8s.container.name",
additionalAttributes: ["resource.k8s.pod.name"],
});
}),
fetchK8sObjectsBatch({
clusterIdentifier: cluster.clusterIdentifier,
resourceType: "pods",
}),
]);
for (const resource of containerList) {
const podName: string =
resource.additionalAttributes["resource.k8s.pod.name"] || "";
const podKey: string = resource.namespace
? `${resource.namespace}/${podName}`
: podName;
const podObj: KubernetesObjectType | undefined = podObjects.get(podKey);
if (podObj) {
const pod: KubernetesPodObject = podObj as KubernetesPodObject;
// Find the container status matching this container name
const containerStatus = pod.status.containerStatuses.find(
(cs) => cs.name === resource.name,
);
if (containerStatus) {
if (containerStatus.state === "running") {
resource.status = containerStatus.ready ? "Running" : "NotReady";
} else if (containerStatus.state === "waiting") {
resource.status = "Waiting";
} else if (containerStatus.state === "terminated") {
resource.status = "Terminated";
} else {
resource.status = containerStatus.state || "Unknown";
}
resource.additionalAttributes["restarts"] =
`${containerStatus.restartCount}`;
}
resource.age = KubernetesResourceUtils.formatAge(
pod.metadata.creationTimestamp,
);
}
}
setResources(containerList);
} catch (err) {
@@ -79,28 +122,30 @@ const KubernetesClusterContainers: FunctionComponent<
}
return (
<Fragment>
<KubernetesResourceTable
title="Containers"
description="All containers running in this cluster."
resources={resources}
columns={[
<KubernetesResourceTable
title="Containers"
description="All containers running in this cluster."
resources={resources}
columns={[
{
title: "Pod",
key: "resource.k8s.pod.name",
},
{
title: "Restarts",
key: "restarts",
},
]}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL] as Route,
{
title: "Pod",
key: "resource.k8s.pod.name",
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
]}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
</Fragment>
);
}}
/>
);
};

View File

@@ -21,6 +21,7 @@ import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils";
const KubernetesClusterControlPlane: FunctionComponent<
PageComponentProps
@@ -78,7 +79,7 @@ const KubernetesClusterControlPlane: FunctionComponent<
title: "etcd Database Size",
description: "Total size of the etcd database",
legend: "DB Size",
legendUnit: "bytes",
legendUnit: "",
},
metricQueryData: {
filterData: {
@@ -93,6 +94,7 @@ const KubernetesClusterControlPlane: FunctionComponent<
attributes: true,
},
},
yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart,
};
const apiServerRequestRateQuery: MetricQueryConfigData = {

View File

@@ -3,13 +3,12 @@ import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import MetricQueryConfigData, {
ChartSeries,
} from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
@@ -28,6 +27,12 @@ import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEvents
import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab";
import { KubernetesCronJobObject } 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";
import KubernetesResourceLink from "../../../Components/Kubernetes/KubernetesResourceLink";
const KubernetesClusterCronJobDetail: FunctionComponent<
PageComponentProps
@@ -145,7 +150,7 @@ const KubernetesClusterCronJobDetail: FunctionComponent<
title: "Pod Memory Usage",
description: `Memory usage for pods in cronjob ${cronJobName}`,
legend: "Memory",
legendUnit: "bytes",
legendUnit: "",
},
metricQueryData: {
filterData: {
@@ -162,6 +167,7 @@ const KubernetesClusterCronJobDetail: FunctionComponent<
},
},
getSeries: getSeries,
yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart,
};
// Build overview summary fields from cronjob object
@@ -175,7 +181,15 @@ const KubernetesClusterCronJobDetail: FunctionComponent<
summaryFields.push(
{
title: "Namespace",
value: cronJobObject.metadata.namespace || "default",
value: cronJobObject.metadata.namespace ? (
<KubernetesResourceLink
modelId={modelId}
resourceKind="Namespace"
resourceName={cronJobObject.metadata.namespace}
/>
) : (
"default"
),
},
{
title: "Schedule",
@@ -183,7 +197,16 @@ const KubernetesClusterCronJobDetail: FunctionComponent<
},
{
title: "Suspend",
value: cronJobObject.spec.suspend ? "Yes" : "No",
value: (
<StatusBadge
text={cronJobObject.spec.suspend ? "Suspended" : "Active"}
type={
cronJobObject.spec.suspend
? StatusBadgeType.Warning
: StatusBadgeType.Success
}
/>
),
},
{
title: "Concurrency Policy",
@@ -251,20 +274,20 @@ const KubernetesClusterCronJobDetail: FunctionComponent<
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="cronjobs"
resourceName={cronJobName}
namespace={cronJobObject?.metadata.namespace}
/>
),
},
];
return (
<Fragment>
<div className="mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="CronJob Name" value={cronJobName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
</div>
</div>
<Tabs tabs={tabs} onTabChange={() => {}} />
</Fragment>
);
return <Tabs tabs={tabs} onTabChange={() => {}} />;
};
export default KubernetesClusterCronJobDetail;

View File

@@ -6,13 +6,7 @@ import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesRe
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import React, { FunctionComponent, ReactElement, useEffect, useState } from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
@@ -21,6 +15,11 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
import {
fetchK8sObjectsBatch,
KubernetesObjectType,
} from "../Utils/KubernetesObjectFetcher";
import { KubernetesCronJobObject } from "../Utils/KubernetesObjectParser";
const KubernetesClusterCronJobs: FunctionComponent<
PageComponentProps
@@ -48,13 +47,39 @@ const KubernetesClusterCronJobs: FunctionComponent<
return;
}
const cronjobList: Array<KubernetesResource> =
await KubernetesResourceUtils.fetchResourceListWithMemory({
const [cronjobList, cronjobObjects]: [
Array<KubernetesResource>,
Map<string, KubernetesObjectType>,
] = await Promise.all([
KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: cluster.clusterIdentifier,
metricName: "k8s.pod.cpu.utilization",
memoryMetricName: "k8s.pod.memory.usage",
resourceNameAttribute: "resource.k8s.cronjob.name",
});
}),
fetchK8sObjectsBatch({
clusterIdentifier: cluster.clusterIdentifier,
resourceType: "cronjobs",
}),
]);
for (const resource of cronjobList) {
const key: string = `${resource.namespace}/${resource.name}`;
const cjObj: KubernetesObjectType | undefined =
cronjobObjects.get(key);
if (cjObj) {
const cronJob: KubernetesCronJobObject =
cjObj as KubernetesCronJobObject;
resource.status = cronJob.spec.suspend ? "Suspended" : "Active";
resource.additionalAttributes["schedule"] = cronJob.spec.schedule;
resource.age = KubernetesResourceUtils.formatAge(
cronJob.metadata.creationTimestamp,
);
}
}
setResources(cronjobList);
} catch (err) {
@@ -78,22 +103,26 @@ const KubernetesClusterCronJobs: FunctionComponent<
}
return (
<Fragment>
<KubernetesResourceTable
title="CronJobs"
description="All cron jobs in this cluster."
resources={resources}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
</Fragment>
<KubernetesResourceTable
title="CronJobs"
description="All cron jobs in this cluster."
resources={resources}
columns={[
{
title: "Schedule",
key: "schedule",
},
]}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOB_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
);
};

View File

@@ -3,13 +3,12 @@ import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import MetricQueryConfigData, {
ChartSeries,
} from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
@@ -28,6 +27,12 @@ import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEvents
import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab";
import { KubernetesDaemonSetObject } 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";
import KubernetesResourceLink from "../../../Components/Kubernetes/KubernetesResourceLink";
const KubernetesClusterDaemonSetDetail: FunctionComponent<
PageComponentProps
@@ -145,7 +150,7 @@ const KubernetesClusterDaemonSetDetail: FunctionComponent<
title: "Pod Memory Usage",
description: `Memory usage for pods in daemonset ${daemonSetName}`,
legend: "Memory",
legendUnit: "bytes",
legendUnit: "",
},
metricQueryData: {
filterData: {
@@ -162,6 +167,7 @@ const KubernetesClusterDaemonSetDetail: FunctionComponent<
},
},
getSeries: getSeries,
yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart,
};
// Build overview summary fields from daemonset object
@@ -175,7 +181,15 @@ const KubernetesClusterDaemonSetDetail: FunctionComponent<
summaryFields.push(
{
title: "Namespace",
value: objectData.metadata.namespace || "default",
value: objectData.metadata.namespace ? (
<KubernetesResourceLink
modelId={modelId}
resourceKind="Namespace"
resourceName={objectData.metadata.namespace}
/>
) : (
"default"
),
},
{
title: "Desired Scheduled",
@@ -187,11 +201,35 @@ const KubernetesClusterDaemonSetDetail: FunctionComponent<
},
{
title: "Number Ready",
value: String(objectData.status.numberReady ?? "N/A"),
value: (
<StatusBadge
text={`${objectData.status.numberReady ?? 0}/${objectData.status.desiredNumberScheduled ?? 0}`}
type={
(objectData.status.numberReady ?? 0) >=
(objectData.status.desiredNumberScheduled ?? 0)
? StatusBadgeType.Success
: (objectData.status.numberReady ?? 0) > 0
? StatusBadgeType.Warning
: StatusBadgeType.Danger
}
/>
),
},
{
title: "Number Available",
value: String(objectData.status.numberAvailable ?? "N/A"),
value: (
<StatusBadge
text={`${objectData.status.numberAvailable ?? 0}/${objectData.status.desiredNumberScheduled ?? 0}`}
type={
(objectData.status.numberAvailable ?? 0) >=
(objectData.status.desiredNumberScheduled ?? 0)
? StatusBadgeType.Success
: (objectData.status.numberAvailable ?? 0) > 0
? StatusBadgeType.Warning
: StatusBadgeType.Danger
}
/>
),
},
{
title: "Update Strategy",
@@ -243,20 +281,20 @@ const KubernetesClusterDaemonSetDetail: FunctionComponent<
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="daemonsets"
resourceName={daemonSetName}
namespace={objectData?.metadata.namespace}
/>
),
},
];
return (
<Fragment>
<div className="mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="DaemonSet" value={daemonSetName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
</div>
</div>
<Tabs tabs={tabs} onTabChange={() => {}} />
</Fragment>
);
return <Tabs tabs={tabs} onTabChange={() => {}} />;
};
export default KubernetesClusterDaemonSetDetail;

View File

@@ -6,13 +6,7 @@ import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesRe
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import React, { FunctionComponent, ReactElement, useEffect, useState } from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
@@ -21,6 +15,11 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
import {
fetchK8sObjectsBatch,
KubernetesObjectType,
} from "../Utils/KubernetesObjectFetcher";
import { KubernetesDaemonSetObject } from "../Utils/KubernetesObjectParser";
const KubernetesClusterDaemonSets: FunctionComponent<
PageComponentProps
@@ -48,13 +47,44 @@ const KubernetesClusterDaemonSets: FunctionComponent<
return;
}
const daemonsetList: Array<KubernetesResource> =
await KubernetesResourceUtils.fetchResourceListWithMemory({
const [daemonsetList, daemonsetObjects]: [
Array<KubernetesResource>,
Map<string, KubernetesObjectType>,
] = await Promise.all([
KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: cluster.clusterIdentifier,
metricName: "k8s.pod.cpu.utilization",
memoryMetricName: "k8s.pod.memory.usage",
resourceNameAttribute: "resource.k8s.daemonset.name",
});
}),
fetchK8sObjectsBatch({
clusterIdentifier: cluster.clusterIdentifier,
resourceType: "daemonsets",
}),
]);
for (const resource of daemonsetList) {
const key: string = `${resource.namespace}/${resource.name}`;
const dsObj: KubernetesObjectType | undefined =
daemonsetObjects.get(key);
if (dsObj) {
const ds: KubernetesDaemonSetObject =
dsObj as KubernetesDaemonSetObject;
const numberReady: number = ds.status.numberReady;
const desired: number = ds.status.desiredNumberScheduled;
resource.status =
numberReady === desired && desired > 0 ? "Ready" : "Progressing";
resource.additionalAttributes["ready"] =
`${numberReady}/${desired}`;
resource.age = KubernetesResourceUtils.formatAge(
ds.metadata.creationTimestamp,
);
}
}
setResources(daemonsetList);
} catch (err) {
@@ -78,22 +108,26 @@ const KubernetesClusterDaemonSets: FunctionComponent<
}
return (
<Fragment>
<KubernetesResourceTable
title="DaemonSets"
description="All daemonsets running in this cluster."
resources={resources}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
</Fragment>
<KubernetesResourceTable
title="DaemonSets"
description="All daemonsets running in this cluster."
resources={resources}
columns={[
{
title: "Ready",
key: "ready",
},
]}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSET_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
);
};

View File

@@ -3,13 +3,12 @@ import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import MetricQueryConfigData, {
ChartSeries,
} from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
@@ -28,6 +27,12 @@ import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEvents
import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab";
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";
import KubernetesResourceLink from "../../../Components/Kubernetes/KubernetesResourceLink";
const KubernetesClusterDeploymentDetail: FunctionComponent<
PageComponentProps
@@ -145,7 +150,7 @@ const KubernetesClusterDeploymentDetail: FunctionComponent<
title: "Pod Memory Usage",
description: `Memory usage for pods in deployment ${deploymentName}`,
legend: "Memory",
legendUnit: "bytes",
legendUnit: "",
},
metricQueryData: {
filterData: {
@@ -162,6 +167,7 @@ const KubernetesClusterDeploymentDetail: FunctionComponent<
},
},
getSeries: getSeries,
yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart,
};
// Build overview summary fields from deployment object
@@ -172,30 +178,92 @@ const KubernetesClusterDeploymentDetail: FunctionComponent<
];
if (objectData) {
const desired: number = objectData.spec.replicas;
const ready: number = objectData.status.readyReplicas ?? 0;
const available: number = objectData.status.availableReplicas ?? 0;
const unavailable: number = objectData.status.unavailableReplicas ?? 0;
const isFullyRolledOut: boolean =
ready === desired && unavailable === 0;
summaryFields.push(
{
title: "Namespace",
value: objectData.metadata.namespace || "default",
value: objectData.metadata.namespace ? (
<KubernetesResourceLink
modelId={modelId}
resourceKind="Namespace"
resourceName={objectData.metadata.namespace}
/>
) : (
"default"
),
},
{
title: "Replicas",
value: String(objectData.spec.replicas ?? "N/A"),
title: "Rollout Status",
value: (
<div>
<div className="flex items-center gap-2 mb-1">
<StatusBadge
text={isFullyRolledOut ? "Complete" : "In Progress"}
type={
isFullyRolledOut
? StatusBadgeType.Success
: StatusBadgeType.Warning
}
/>
<span className="text-sm text-gray-600">
{ready}/{desired} ready
</span>
</div>
<div className="w-32 bg-gray-100 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${isFullyRolledOut ? "bg-emerald-500" : "bg-amber-500"}`}
style={{
width: `${desired > 0 ? (ready / desired) * 100 : 0}%`,
}}
/>
</div>
</div>
),
},
{
title: "Desired Replicas",
value: String(desired),
},
{
title: "Ready Replicas",
value: String(objectData.status.readyReplicas ?? "N/A"),
value: String(ready),
},
{
title: "Available Replicas",
value: String(objectData.status.availableReplicas ?? "N/A"),
title: "Available",
value: String(available),
},
);
if (unavailable > 0) {
summaryFields.push({
title: "Unavailable",
value: (
<StatusBadge
text={String(unavailable)}
type={StatusBadgeType.Danger}
/>
),
});
}
summaryFields.push(
{
title: "Strategy",
value: objectData.spec.strategy || "N/A",
},
{
title: "Created",
value: objectData.metadata.creationTimestamp || "N/A",
value: objectData.metadata.creationTimestamp
? KubernetesResourceUtils.formatAge(
objectData.metadata.creationTimestamp,
)
: "N/A",
},
);
}
@@ -240,20 +308,20 @@ const KubernetesClusterDeploymentDetail: FunctionComponent<
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="deployments"
resourceName={deploymentName}
namespace={objectData?.metadata.namespace}
/>
),
},
];
return (
<Fragment>
<div className="mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="Deployment" value={deploymentName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
</div>
</div>
<Tabs tabs={tabs} onTabChange={() => {}} />
</Fragment>
);
return <Tabs tabs={tabs} onTabChange={() => {}} />;
};
export default KubernetesClusterDeploymentDetail;

View File

@@ -6,13 +6,7 @@ import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesRe
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import React, { FunctionComponent, ReactElement, useEffect, useState } from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
@@ -21,6 +15,11 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
import {
fetchK8sObjectsBatch,
KubernetesObjectType,
} from "../Utils/KubernetesObjectFetcher";
import { KubernetesDeploymentObject } from "../Utils/KubernetesObjectParser";
const KubernetesClusterDeployments: FunctionComponent<
PageComponentProps
@@ -48,13 +47,53 @@ const KubernetesClusterDeployments: FunctionComponent<
return;
}
const deploymentList: Array<KubernetesResource> =
await KubernetesResourceUtils.fetchResourceListWithMemory({
const [deploymentList, deploymentObjects]: [
Array<KubernetesResource>,
Map<string, KubernetesObjectType>,
] = await Promise.all([
KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: cluster.clusterIdentifier,
metricName: "k8s.pod.cpu.utilization",
memoryMetricName: "k8s.pod.memory.usage",
resourceNameAttribute: "resource.k8s.deployment.name",
});
}),
fetchK8sObjectsBatch({
clusterIdentifier: cluster.clusterIdentifier,
resourceType: "deployments",
}),
]);
for (const resource of deploymentList) {
const key: string = `${resource.namespace}/${resource.name}`;
const depObj: KubernetesObjectType | undefined =
deploymentObjects.get(key);
if (depObj) {
const deployment: KubernetesDeploymentObject =
depObj as KubernetesDeploymentObject;
const readyReplicas: number = deployment.status.readyReplicas;
const replicas: number = deployment.spec.replicas;
if (readyReplicas === replicas && replicas > 0) {
resource.status = "Ready";
} else if (readyReplicas < replicas) {
// Check conditions for failure
const failedCondition = deployment.status.conditions.find(
(c) => c.type === "Available" && c.status === "False",
);
resource.status = failedCondition ? "Failed" : "Progressing";
} else {
resource.status = "Progressing";
}
resource.additionalAttributes["ready"] =
`${readyReplicas}/${replicas}`;
resource.age = KubernetesResourceUtils.formatAge(
deployment.metadata.creationTimestamp,
);
}
}
setResources(deploymentList);
} catch (err) {
@@ -78,24 +117,28 @@ const KubernetesClusterDeployments: FunctionComponent<
}
return (
<Fragment>
<KubernetesResourceTable
title="Deployments"
description="All deployments running in this cluster."
resources={resources}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL
] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
</Fragment>
<KubernetesResourceTable
title="Deployments"
description="All deployments running in this cluster."
resources={resources}
columns={[
{
title: "Ready",
key: "ready",
},
]}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENT_DETAIL
] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
);
};

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

View File

@@ -0,0 +1,233 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import Tabs from "Common/UI/Components/Tabs/Tabs";
import { Tab } from "Common/UI/Components/Tabs/Tab";
import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOverviewTab";
import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab";
import { KubernetesHPAObject } 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";
import KubernetesResourceLink from "../../../Components/Kubernetes/KubernetesResourceLink";
const KubernetesClusterHPADetail: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(2);
const hpaName: string = Navigation.getLastParamAsString();
const [cluster, setCluster] = useState<KubernetesCluster | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [objectData, setObjectData] =
useState<KubernetesHPAObject | null>(null);
const [isLoadingObject, setIsLoadingObject] = useState<boolean>(true);
const fetchCluster: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const item: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
setCluster(item);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchCluster().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
useEffect(() => {
if (!cluster?.clusterIdentifier) {
return;
}
const fetchObject: () => Promise<void> = async (): Promise<void> => {
setIsLoadingObject(true);
try {
const obj: KubernetesHPAObject | null =
await fetchLatestK8sObject<KubernetesHPAObject>({
clusterIdentifier: cluster.clusterIdentifier || "",
resourceType: "horizontalpodautoscalers",
resourceName: hpaName,
});
setObjectData(obj);
} catch {
// Graceful degradation — overview tab shows empty state
}
setIsLoadingObject(false);
};
fetchObject().catch(() => {});
}, [cluster?.clusterIdentifier, hpaName]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (!cluster) {
return <ErrorMessage message="Cluster not found." />;
}
const clusterIdentifier: string = cluster.clusterIdentifier || "";
const summaryFields: Array<{ title: string; value: string | ReactElement }> =
[
{ title: "Name", value: hpaName },
{ title: "Cluster", value: clusterIdentifier },
];
if (objectData) {
const currentReplicas: number = objectData.status.currentReplicas;
const desiredReplicas: number = objectData.status.desiredReplicas;
const isStable: boolean = currentReplicas === desiredReplicas;
summaryFields.push(
{
title: "Namespace",
value: objectData.metadata.namespace ? (
<KubernetesResourceLink
modelId={modelId}
resourceKind="Namespace"
resourceName={objectData.metadata.namespace}
/>
) : (
"default"
),
},
{
title: "Target Kind",
value: objectData.spec.scaleTargetRef.kind || "N/A",
},
{
title: "Target Name",
value: objectData.spec.scaleTargetRef.name || "N/A",
},
{
title: "Min Replicas",
value: String(objectData.spec.minReplicas),
},
{
title: "Max Replicas",
value: String(objectData.spec.maxReplicas),
},
{
title: "Current Replicas",
value: String(currentReplicas),
},
{
title: "Desired Replicas",
value: String(desiredReplicas),
},
{
title: "Scaling Status",
value: (
<StatusBadge
text={isStable ? "Stable" : "Scaling"}
type={
isStable ? StatusBadgeType.Success : StatusBadgeType.Warning
}
/>
),
},
{
title: "Created",
value: objectData.metadata.creationTimestamp
? KubernetesResourceUtils.formatAge(
objectData.metadata.creationTimestamp,
)
: "N/A",
},
);
if (objectData.spec.metrics.length > 0) {
const metricsDisplay: string = objectData.spec.metrics
.map((m) => {
if (m.resourceName) {
return `${m.resourceName} (${m.targetType}: ${m.targetValue})`;
}
return m.type;
})
.join(", ");
summaryFields.push({
title: "Metrics",
value: metricsDisplay || "N/A",
});
}
}
const tabs: Array<Tab> = [
{
name: "Overview",
children: (
<KubernetesOverviewTab
summaryFields={summaryFields}
labels={objectData?.metadata.labels || {}}
annotations={objectData?.metadata.annotations || {}}
isLoading={isLoadingObject}
/>
),
},
{
name: "Events",
children: (
<Card
title="HPA Events"
description="Kubernetes events for this HPA in the last 24 hours."
>
<KubernetesEventsTab
clusterIdentifier={clusterIdentifier}
resourceKind="HorizontalPodAutoscaler"
resourceName={hpaName}
namespace={objectData?.metadata.namespace}
/>
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="horizontalpodautoscalers"
resourceName={hpaName}
namespace={objectData?.metadata.namespace}
/>
),
},
];
return <Tabs tabs={tabs} onTabChange={() => {}} />;
};
export default KubernetesClusterHPADetail;

View File

@@ -0,0 +1,164 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable";
import {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils";
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
import {
fetchK8sObjectsBatch,
KubernetesObjectType,
} from "../Utils/KubernetesObjectFetcher";
import { KubernetesHPAObject } from "../Utils/KubernetesObjectParser";
const KubernetesClusterHPAs: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [resources, setResources] = useState<Array<KubernetesResource>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const fetchData: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const cluster: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
if (!cluster?.clusterIdentifier) {
setError("Cluster not found.");
setIsLoading(false);
return;
}
const hpaObjects: Map<string, KubernetesObjectType> =
await fetchK8sObjectsBatch({
clusterIdentifier: cluster.clusterIdentifier,
resourceType: "horizontalpodautoscalers",
});
const hpaResources: Array<KubernetesResource> = [];
for (const hpaObj of hpaObjects.values()) {
const hpa: KubernetesHPAObject = hpaObj as KubernetesHPAObject;
const currentReplicas: number = hpa.status.currentReplicas;
const desiredReplicas: number = hpa.status.desiredReplicas;
let status: string = "Active";
if (currentReplicas === desiredReplicas && currentReplicas > 0) {
status = "Active";
} else if (currentReplicas < desiredReplicas) {
status = "Scaling Up";
} else if (currentReplicas > desiredReplicas) {
status = "Scaling Down";
}
hpaResources.push({
name: hpa.metadata.name,
namespace: hpa.metadata.namespace || "default",
cpuUtilization: null,
memoryUsageBytes: null,
memoryLimitBytes: null,
status: status,
age: KubernetesResourceUtils.formatAge(
hpa.metadata.creationTimestamp,
),
additionalAttributes: {
target: `${hpa.spec.scaleTargetRef.kind}/${hpa.spec.scaleTargetRef.name}`,
minReplicas: String(hpa.spec.minReplicas),
maxReplicas: String(hpa.spec.maxReplicas),
currentReplicas: String(currentReplicas),
desiredReplicas: String(desiredReplicas),
},
});
}
setResources(hpaResources);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchData().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
return (
<KubernetesResourceTable
title="Horizontal Pod Autoscalers"
description="All HPAs in this cluster with their current scaling status."
resources={resources}
showResourceMetrics={false}
columns={[
{
title: "Target",
key: "target",
},
{
title: "Min Replicas",
key: "minReplicas",
},
{
title: "Max Replicas",
key: "maxReplicas",
},
{
title: "Current",
key: "currentReplicas",
},
{
title: "Desired",
key: "desiredReplicas",
},
]}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_HPA_DETAIL
] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
emptyMessage="No HPAs found. HPA data will appear here once the kubernetes-agent Helm chart has resourceSpecs.enabled set to true and includes horizontalpodautoscalers."
/>
);
};
export default KubernetesClusterHPAs;

View File

@@ -22,11 +22,58 @@ import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import {
fetchK8sObjectsBatch,
fetchClusterWarningEvents,
KubernetesObjectType,
KubernetesEvent,
} from "../Utils/KubernetesObjectFetcher";
import {
KubernetesPodObject,
KubernetesNodeObject,
} from "../Utils/KubernetesObjectParser";
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;
description: string;
pageMap: PageMap;
count?: number | undefined;
}
function formatRelativeTime(timestamp: string): string {
try {
const eventDate: Date = new Date(timestamp);
const now: Date = new Date();
const diffMs: number = now.getTime() - eventDate.getTime();
const diffMins: number = Math.floor(diffMs / 60000);
if (diffMins < 1) {
return "just now";
}
if (diffMins < 60) {
return `${diffMins}m ago`;
}
const diffHours: number = Math.floor(diffMins / 60);
if (diffHours < 24) {
return `${diffHours}h ago`;
}
const diffDays: number = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
} catch {
return timestamp;
}
}
const KubernetesClusterOverview: FunctionComponent<
@@ -37,6 +84,34 @@ const KubernetesClusterOverview: FunctionComponent<
const [cluster, setCluster] = useState<KubernetesCluster | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [nodeCount, setNodeCount] = useState<number>(0);
const [podCount, setPodCount] = useState<number>(0);
const [namespaceCount, setNamespaceCount] = useState<number>(0);
const [podHealthSummary, setPodHealthSummary] = useState<{
running: number;
pending: number;
failed: number;
succeeded: number;
}>({ running: 0, pending: 0, failed: 0, succeeded: 0 });
const [nodeHealthSummary, setNodeHealthSummary] = useState<{
ready: number;
notReady: number;
}>({ ready: 0, notReady: 0 });
const [clusterHealth, setClusterHealth] = useState<
"Healthy" | "Degraded" | "Unhealthy"
>("Healthy");
const [topCpuPods, setTopCpuPods] = useState<Array<KubernetesResource>>([]);
const [topMemoryPods, setTopMemoryPods] = useState<
Array<KubernetesResource>
>([]);
const [recentWarnings, setRecentWarnings] = useState<
Array<KubernetesEvent>
>([]);
const [nodePressure, setNodePressure] = useState<{
memoryPressure: number;
diskPressure: number;
pidPressure: number;
}>({ memoryPressure: 0, diskPressure: 0, pidPressure: 0 });
const fetchCluster: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
@@ -50,12 +125,177 @@ const KubernetesClusterOverview: FunctionComponent<
provider: true,
otelCollectorStatus: true,
lastSeenAt: true,
nodeCount: true,
podCount: true,
namespaceCount: true,
},
});
setCluster(item);
if (item?.clusterIdentifier) {
// Fetch counts dynamically from metrics data
const [nodes, pods, namespaces]: [
Array<KubernetesResource>,
Array<KubernetesResource>,
Array<KubernetesResource>,
] = await Promise.all([
KubernetesResourceUtils.fetchResourceList({
clusterIdentifier: item.clusterIdentifier,
metricName: "k8s.node.cpu.utilization",
resourceNameAttribute: "resource.k8s.node.name",
namespaceAttribute: "resource.k8s.node.name",
}),
KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: item.clusterIdentifier,
metricName: "k8s.pod.cpu.utilization",
resourceNameAttribute: "resource.k8s.pod.name",
memoryMetricName: "k8s.pod.memory.usage",
}),
KubernetesResourceUtils.fetchResourceList({
clusterIdentifier: item.clusterIdentifier,
metricName: "k8s.pod.cpu.utilization",
resourceNameAttribute: "resource.k8s.namespace.name",
namespaceAttribute: "resource.k8s.namespace.name",
}),
]);
setNodeCount(nodes.length);
setPodCount(pods.length);
setNamespaceCount(namespaces.length);
// Top resource consumers
const sortedByCpu: Array<KubernetesResource> = [...pods]
.filter(
(p: KubernetesResource) =>
p.cpuUtilization !== null && p.cpuUtilization !== undefined,
)
.sort(
(a: KubernetesResource, b: KubernetesResource) =>
(b.cpuUtilization ?? 0) - (a.cpuUtilization ?? 0),
)
.slice(0, 5);
setTopCpuPods(sortedByCpu);
const sortedByMemory: Array<KubernetesResource> = [...pods]
.filter(
(p: KubernetesResource) =>
p.memoryUsageBytes !== null &&
p.memoryUsageBytes !== undefined,
)
.sort(
(a: KubernetesResource, b: KubernetesResource) =>
(b.memoryUsageBytes ?? 0) - (a.memoryUsageBytes ?? 0),
)
.slice(0, 5);
setTopMemoryPods(sortedByMemory);
// Fetch pod and node objects for health status
try {
const [podObjects, nodeObjects]: [
Map<string, KubernetesObjectType>,
Map<string, KubernetesObjectType>,
] = await Promise.all([
fetchK8sObjectsBatch({
clusterIdentifier: item.clusterIdentifier,
resourceType: "pods",
}),
fetchK8sObjectsBatch({
clusterIdentifier: item.clusterIdentifier,
resourceType: "nodes",
}),
]);
// Calculate pod health
let running: number = 0;
let pending: number = 0;
let failed: number = 0;
let succeeded: number = 0;
for (const podObj of podObjects.values()) {
const pod: KubernetesPodObject =
podObj as KubernetesPodObject;
const phase: string = pod.status.phase || "Unknown";
if (phase === "Running") {
running++;
} else if (phase === "Pending") {
pending++;
} else if (phase === "Failed") {
failed++;
} else if (phase === "Succeeded") {
succeeded++;
}
}
setPodHealthSummary({ running, pending, failed, succeeded });
// Calculate node health and pressure
let ready: number = 0;
let notReady: number = 0;
let memPressure: number = 0;
let diskPressure: number = 0;
let pidPressure: number = 0;
for (const nodeObj of nodeObjects.values()) {
const node: KubernetesNodeObject =
nodeObj as KubernetesNodeObject;
const readyCondition: boolean = node.status.conditions.some(
(c: { type: string; status: string }) =>
c.type === "Ready" && c.status === "True",
);
if (readyCondition) {
ready++;
} else {
notReady++;
}
// Check pressure conditions
for (const cond of node.status.conditions) {
if (
cond.type === "MemoryPressure" &&
cond.status === "True"
) {
memPressure++;
}
if (
cond.type === "DiskPressure" &&
cond.status === "True"
) {
diskPressure++;
}
if (
cond.type === "PIDPressure" &&
cond.status === "True"
) {
pidPressure++;
}
}
}
setNodeHealthSummary({ ready, notReady });
setNodePressure({
memoryPressure: memPressure,
diskPressure: diskPressure,
pidPressure: pidPressure,
});
// Determine overall health
if (failed > 0 || notReady > 0) {
setClusterHealth("Unhealthy");
} else if (pending > 0) {
setClusterHealth("Degraded");
} else {
setClusterHealth("Healthy");
}
} catch {
// Health data is supplementary, don't fail
}
// Fetch recent warning events
try {
const warnings: Array<KubernetesEvent> =
await fetchClusterWarningEvents({
clusterIdentifier: item.clusterIdentifier,
limit: 5,
});
setRecentWarnings(warnings);
} catch {
// Warnings are supplementary
}
}
} catch (err) {
setError(API.getFriendlyMessage(err));
}
@@ -80,21 +320,25 @@ 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> = [
{
title: "Namespaces",
description: "View all namespaces",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACES,
count: namespaceCount > 0 ? namespaceCount : undefined,
},
{
title: "Pods",
description: "View all pods",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_PODS,
count: podCount > 0 ? podCount : undefined,
},
{
title: "Deployments",
@@ -128,23 +372,180 @@ const KubernetesClusterOverview: FunctionComponent<
title: "Nodes",
description: "View all nodes",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_NODES,
count: nodeCount > 0 ? nodeCount : undefined,
},
{
title: "Containers",
description: "View all containers",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINERS,
},
{
title: "PVCs",
description: "View persistent volume claims",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_PVCS,
},
{
title: "PVs",
description: "View persistent volumes",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_PVS,
},
];
// 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 justify-between 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>
{link.count !== undefined && (
<span className="inline-flex items-center justify-center min-w-[1.5rem] h-6 px-2 text-xs font-semibold rounded-full bg-indigo-100 text-indigo-700">
{link.count}
</span>
)}
</div>
);
})}
</div>
);
};
return (
<Fragment>
{/* Cluster Health Banner */}
<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-emerald-700">
{podHealthSummary.running}
</span>{" "}
Running
</span>
{podHealthSummary.pending > 0 && (
<span className="text-gray-600">
<span className="font-medium text-amber-700">
{podHealthSummary.pending}
</span>{" "}
Pending
</span>
)}
{podHealthSummary.failed > 0 && (
<span className="text-gray-600">
<span className="font-medium text-red-700">
{podHealthSummary.failed}
</span>{" "}
Failed
</span>
)}
{nodeHealthSummary.notReady > 0 && (
<span className="text-gray-600">
<span className="font-medium text-red-700">
{nodeHealthSummary.notReady}
</span>{" "}
Nodes Not Ready
</span>
)}
</div>
}
/>
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mb-5">
<InfoCard
title="Cluster Health"
value={
<span
className={`text-2xl font-semibold ${
clusterHealth === "Healthy"
? "text-emerald-600"
: clusterHealth === "Degraded"
? "text-amber-600"
: "text-red-600"
}`}
>
{clusterHealth}
</span>
}
/>
<InfoCard
title="Nodes"
value={
<span className="text-2xl font-semibold">
{cluster.nodeCount?.toString() || "0"}
{nodeCount.toString()}
{nodeHealthSummary.notReady > 0 && (
<span className="text-sm text-red-500 ml-1">
({nodeHealthSummary.notReady} not ready)
</span>
)}
</span>
}
/>
@@ -152,7 +553,7 @@ const KubernetesClusterOverview: FunctionComponent<
title="Pods"
value={
<span className="text-2xl font-semibold">
{cluster.podCount?.toString() || "0"}
{podCount.toString()}
</span>
}
/>
@@ -160,18 +561,25 @@ const KubernetesClusterOverview: FunctionComponent<
title="Namespaces"
value={
<span className="text-2xl font-semibold">
{cluster.namespaceCount?.toString() || "0"}
{namespaceCount.toString()}
</span>
}
/>
<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>
@@ -181,29 +589,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 (
<a
key={link.title}
href={RouteUtil.populateRouteParams(
RouteMap[link.pageMap] as Route,
{ modelId: modelId },
).toString()}
className="flex items-center p-3 rounded-lg border border-gray-200 hover:border-indigo-300 hover:bg-indigo-50 transition-colors group"
>
<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>
</a>
);
})}
</div>
{renderResourceLinks(workloadLinks)}
</Card>
{/* Quick Navigation - Infrastructure */}
@@ -211,31 +597,198 @@ 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 (
<a
key={link.title}
href={RouteUtil.populateRouteParams(
RouteMap[link.pageMap] as Route,
{ modelId: modelId },
).toString()}
className="flex items-center p-3 rounded-lg border border-gray-200 hover:border-indigo-300 hover:bg-indigo-50 transition-colors group"
>
<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>
</a>
);
})}
</div>
{renderResourceLinks(infraLinks)}
</Card>
{/* Node Pressure Indicators */}
{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>
</AlertBanner>
)}
{/* Pod Health Visual Breakdown */}
{podCount > 0 && (
<Card
title="Pod Health"
description="Distribution of pod statuses across the cluster."
>
<div className="p-4">
<StackedProgressBar
segments={podHealthSegments}
totalValue={podCount}
/>
</div>
</Card>
)}
{/* Top Resource Consumers */}
{(topCpuPods.length > 0 || topMemoryPods.length > 0) && (
<Card
title="Top Resource Consumers"
description="Pods with the highest resource utilization."
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 p-4">
{/* Top CPU */}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-3">
Top CPU Usage
</h4>
<div className="space-y-2">
{topCpuPods.map(
(pod: KubernetesResource, index: number) => {
return (
<div
key={index}
onClick={() => {
Navigation.navigate(
RouteUtil.populateRouteParams(
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL
] as Route,
{
modelId: modelId,
subModelId: new ObjectID(pod.name),
},
),
);
}}
className="cursor-pointer hover:bg-gray-50 rounded -mx-1 px-1 transition-colors"
>
<ResourceUsageBar
label={pod.name}
value={Math.min(pod.cpuUtilization ?? 0, 100)}
valueLabel={KubernetesResourceUtils.formatCpuValue(
pod.cpuUtilization,
)}
secondaryLabel={pod.namespace}
/>
</div>
);
},
)}
</div>
</div>
{/* Top Memory */}
<div>
<h4 className="text-sm font-semibold text-gray-700 mb-3">
Top Memory Usage
</h4>
<div className="space-y-2">
{topMemoryPods.map(
(pod: KubernetesResource, index: number) => {
const maxMemory: number =
topMemoryPods[0]?.memoryUsageBytes ?? 1;
const memPercent: number =
maxMemory > 0
? ((pod.memoryUsageBytes ?? 0) / maxMemory) * 100
: 0;
return (
<div
key={index}
onClick={() => {
Navigation.navigate(
RouteUtil.populateRouteParams(
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL
] as Route,
{
modelId: modelId,
subModelId: new ObjectID(pod.name),
},
),
);
}}
className="cursor-pointer hover:bg-gray-50 rounded -mx-1 px-1 transition-colors"
>
<ResourceUsageBar
label={pod.name}
value={memPercent}
valueLabel={KubernetesResourceUtils.formatMemoryValue(
pod.memoryUsageBytes,
)}
secondaryLabel={pod.namespace}
/>
</div>
);
},
)}
</div>
</div>
</div>
</Card>
)}
{/* Recent Warning Events */}
{recentWarnings.length > 0 && (
<Card
title="Recent Warnings"
description="Latest warning events from the cluster."
>
<div className="p-4">
<div className="space-y-3">
{recentWarnings.map(
(event: KubernetesEvent, index: number) => {
return (
<div
key={index}
className="flex items-start gap-3 p-3 rounded-lg bg-amber-50/50 border border-amber-100"
>
<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}
</div>
<div className="text-xs text-gray-500 mt-1">
<span className="font-medium">{event.objectKind}/{event.objectName}</span>{" "}
in {event.namespace} &middot;{" "}
{formatRelativeTime(event.timestamp)}
</div>
</div>
</div>
);
},
)}
</div>
<div className="mt-3">
<span
onClick={() => {
Navigation.navigate(
RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS] as Route,
{ modelId: modelId },
),
);
}}
className="text-sm text-indigo-600 hover:text-indigo-800 cursor-pointer font-medium"
>
View All Events
</span>
</div>
</div>
</Card>
)}
{/* Cluster Details */}
<CardModelDetail<KubernetesCluster>
name="Cluster Details"

View File

@@ -3,13 +3,12 @@ import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import MetricQueryConfigData, {
ChartSeries,
} from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
@@ -28,6 +27,12 @@ import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEvents
import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab";
import { KubernetesJobObject } 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";
import KubernetesResourceLink from "../../../Components/Kubernetes/KubernetesResourceLink";
const KubernetesClusterJobDetail: FunctionComponent<
PageComponentProps
@@ -144,7 +149,7 @@ const KubernetesClusterJobDetail: FunctionComponent<
title: "Pod Memory Usage",
description: `Memory usage for pods in job ${jobName}`,
legend: "Memory",
legendUnit: "bytes",
legendUnit: "",
},
metricQueryData: {
filterData: {
@@ -161,6 +166,7 @@ const KubernetesClusterJobDetail: FunctionComponent<
},
},
getSeries: getSeries,
yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart,
};
// Build overview summary fields from job object
@@ -174,7 +180,15 @@ const KubernetesClusterJobDetail: FunctionComponent<
summaryFields.push(
{
title: "Namespace",
value: jobObject.metadata.namespace || "default",
value: jobObject.metadata.namespace ? (
<KubernetesResourceLink
modelId={modelId}
resourceKind="Namespace"
resourceName={jobObject.metadata.namespace}
/>
) : (
"default"
),
},
{
title: "Completions",
@@ -190,15 +204,42 @@ const KubernetesClusterJobDetail: FunctionComponent<
},
{
title: "Active",
value: String(jobObject.status.active ?? 0),
value: (
<StatusBadge
text={String(jobObject.status.active ?? 0)}
type={
(jobObject.status.active ?? 0) > 0
? StatusBadgeType.Info
: StatusBadgeType.Neutral
}
/>
),
},
{
title: "Succeeded",
value: String(jobObject.status.succeeded ?? 0),
value: (
<StatusBadge
text={String(jobObject.status.succeeded ?? 0)}
type={
(jobObject.status.succeeded ?? 0) > 0
? StatusBadgeType.Success
: StatusBadgeType.Neutral
}
/>
),
},
{
title: "Failed",
value: String(jobObject.status.failed ?? 0),
value: (
<StatusBadge
text={String(jobObject.status.failed ?? 0)}
type={
(jobObject.status.failed ?? 0) > 0
? StatusBadgeType.Danger
: StatusBadgeType.Neutral
}
/>
),
},
{
title: "Start Time",
@@ -255,20 +296,20 @@ const KubernetesClusterJobDetail: FunctionComponent<
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="jobs"
resourceName={jobName}
namespace={jobObject?.metadata.namespace}
/>
),
},
];
return (
<Fragment>
<div className="mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="Job Name" value={jobName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
</div>
</div>
<Tabs tabs={tabs} onTabChange={() => {}} />
</Fragment>
);
return <Tabs tabs={tabs} onTabChange={() => {}} />;
};
export default KubernetesClusterJobDetail;

View File

@@ -6,13 +6,7 @@ import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesRe
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import React, { FunctionComponent, ReactElement, useEffect, useState } from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
@@ -21,6 +15,11 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
import {
fetchK8sObjectsBatch,
KubernetesObjectType,
} from "../Utils/KubernetesObjectFetcher";
import { KubernetesJobObject } from "../Utils/KubernetesObjectParser";
const KubernetesClusterJobs: FunctionComponent<
PageComponentProps
@@ -48,13 +47,43 @@ const KubernetesClusterJobs: FunctionComponent<
return;
}
const jobList: Array<KubernetesResource> =
await KubernetesResourceUtils.fetchResourceListWithMemory({
const [jobList, jobObjects]: [
Array<KubernetesResource>,
Map<string, KubernetesObjectType>,
] = await Promise.all([
KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: cluster.clusterIdentifier,
metricName: "k8s.pod.cpu.utilization",
memoryMetricName: "k8s.pod.memory.usage",
resourceNameAttribute: "resource.k8s.job.name",
});
}),
fetchK8sObjectsBatch({
clusterIdentifier: cluster.clusterIdentifier,
resourceType: "jobs",
}),
]);
for (const resource of jobList) {
const key: string = `${resource.namespace}/${resource.name}`;
const jobObj: KubernetesObjectType | undefined = jobObjects.get(key);
if (jobObj) {
const job: KubernetesJobObject = jobObj as KubernetesJobObject;
if (job.status.completionTime) {
resource.status = "Complete";
} else if (job.status.failed > 0) {
resource.status = "Failed";
} else if (job.status.active > 0) {
resource.status = "Running";
} else {
resource.status = "Pending";
}
resource.age = KubernetesResourceUtils.formatAge(
job.metadata.creationTimestamp,
);
}
}
setResources(jobList);
} catch (err) {
@@ -78,22 +107,20 @@ const KubernetesClusterJobs: FunctionComponent<
}
return (
<Fragment>
<KubernetesResourceTable
title="Jobs"
description="All jobs in this cluster."
resources={resources}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_JOB_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
</Fragment>
<KubernetesResourceTable
title="Jobs"
description="All jobs in this cluster."
resources={resources}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_JOB_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
);
};

View File

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

View File

@@ -3,13 +3,12 @@ import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import MetricQueryConfigData, {
ChartSeries,
} from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
@@ -28,6 +27,11 @@ import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEvents
import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab";
import { KubernetesNamespaceObject } 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 KubernetesClusterNamespaceDetail: FunctionComponent<
PageComponentProps
@@ -146,7 +150,7 @@ const KubernetesClusterNamespaceDetail: FunctionComponent<
title: "Pod Memory Usage",
description: `Memory usage for pods in namespace ${namespaceName}`,
legend: "Memory",
legendUnit: "bytes",
legendUnit: "",
},
metricQueryData: {
filterData: {
@@ -163,6 +167,7 @@ const KubernetesClusterNamespaceDetail: FunctionComponent<
},
},
getSeries: getSeries,
yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart,
};
// Build overview summary fields from namespace object
@@ -175,8 +180,19 @@ const KubernetesClusterNamespaceDetail: FunctionComponent<
if (namespaceObject) {
summaryFields.push(
{
title: "Status Phase",
value: namespaceObject.status.phase || "N/A",
title: "Status",
value: (
<StatusBadge
text={namespaceObject.status.phase || "Unknown"}
type={
namespaceObject.status.phase === "Active"
? StatusBadgeType.Success
: namespaceObject.status.phase === "Terminating"
? StatusBadgeType.Danger
: StatusBadgeType.Warning
}
/>
),
},
{
title: "Created",
@@ -223,20 +239,19 @@ const KubernetesClusterNamespaceDetail: FunctionComponent<
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="namespaces"
resourceName={namespaceName}
/>
),
},
];
return (
<Fragment>
<div className="mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="Namespace" value={namespaceName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
</div>
</div>
<Tabs tabs={tabs} onTabChange={() => {}} />
</Fragment>
);
return <Tabs tabs={tabs} onTabChange={() => {}} />;
};
export default KubernetesClusterNamespaceDetail;

View File

@@ -6,13 +6,7 @@ import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesRe
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import React, { FunctionComponent, ReactElement, useEffect, useState } from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
@@ -21,6 +15,11 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
import {
fetchK8sObjectsBatch,
KubernetesObjectType,
} from "../Utils/KubernetesObjectFetcher";
import { KubernetesNamespaceObject } from "../Utils/KubernetesObjectParser";
const KubernetesClusterNamespaces: FunctionComponent<
PageComponentProps
@@ -48,14 +47,38 @@ const KubernetesClusterNamespaces: FunctionComponent<
return;
}
const namespaceList: Array<KubernetesResource> =
await KubernetesResourceUtils.fetchResourceListWithMemory({
const [namespaceList, namespaceObjects]: [
Array<KubernetesResource>,
Map<string, KubernetesObjectType>,
] = await Promise.all([
KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: cluster.clusterIdentifier,
metricName: "k8s.pod.cpu.utilization",
memoryMetricName: "k8s.pod.memory.usage",
resourceNameAttribute: "resource.k8s.namespace.name",
namespaceAttribute: "resource.k8s.namespace.name",
});
}),
fetchK8sObjectsBatch({
clusterIdentifier: cluster.clusterIdentifier,
resourceType: "namespaces",
}),
]);
for (const resource of namespaceList) {
const key: string = resource.name;
const nsObj: KubernetesObjectType | undefined =
namespaceObjects.get(key);
if (nsObj) {
const ns: KubernetesNamespaceObject =
nsObj as KubernetesNamespaceObject;
resource.status = ns.status.phase || "Unknown";
resource.age = KubernetesResourceUtils.formatAge(
ns.metadata.creationTimestamp,
);
}
}
setResources(namespaceList);
} catch (err) {
@@ -79,23 +102,21 @@ const KubernetesClusterNamespaces: FunctionComponent<
}
return (
<Fragment>
<KubernetesResourceTable
title="Namespaces"
description="All namespaces in this cluster."
resources={resources}
showNamespace={false}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
</Fragment>
<KubernetesResourceTable
title="Namespaces"
description="All namespaces in this cluster."
resources={resources}
showNamespace={false}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACE_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
);
};

View File

@@ -3,11 +3,10 @@ import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
@@ -28,6 +27,11 @@ import {
KubernetesNodeObject,
} 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 KubernetesClusterNodeDetail: FunctionComponent<
PageComponentProps
@@ -135,7 +139,7 @@ const KubernetesClusterNodeDetail: FunctionComponent<
title: "Memory Usage",
description: `Memory usage for node ${nodeName}`,
legend: "Memory",
legendUnit: "bytes",
legendUnit: "",
},
metricQueryData: {
filterData: {
@@ -151,6 +155,7 @@ const KubernetesClusterNodeDetail: FunctionComponent<
attributes: true,
},
},
yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart,
};
const filesystemQuery: MetricQueryConfigData = {
@@ -159,7 +164,7 @@ const KubernetesClusterNodeDetail: FunctionComponent<
title: "Filesystem Usage",
description: `Filesystem usage for node ${nodeName}`,
legend: "Filesystem",
legendUnit: "bytes",
legendUnit: "",
},
metricQueryData: {
filterData: {
@@ -175,6 +180,7 @@ const KubernetesClusterNodeDetail: FunctionComponent<
attributes: true,
},
},
yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart,
};
const networkRxQuery: MetricQueryConfigData = {
@@ -183,7 +189,7 @@ const KubernetesClusterNodeDetail: FunctionComponent<
title: "Network Receive",
description: `Network bytes received for node ${nodeName}`,
legend: "Network RX",
legendUnit: "bytes/s",
legendUnit: "",
},
metricQueryData: {
filterData: {
@@ -199,6 +205,7 @@ const KubernetesClusterNodeDetail: FunctionComponent<
attributes: true,
},
},
yAxisValueFormatter: KubernetesResourceUtils.formatBytesPerSecForChart,
};
const networkTxQuery: MetricQueryConfigData = {
@@ -207,7 +214,7 @@ const KubernetesClusterNodeDetail: FunctionComponent<
title: "Network Transmit",
description: `Network bytes transmitted for node ${nodeName}`,
legend: "Network TX",
legendUnit: "bytes/s",
legendUnit: "",
},
metricQueryData: {
filterData: {
@@ -223,6 +230,7 @@ const KubernetesClusterNodeDetail: FunctionComponent<
attributes: true,
},
},
yAxisValueFormatter: KubernetesResourceUtils.formatBytesPerSecForChart,
};
// Determine node status from conditions
@@ -253,29 +261,110 @@ const KubernetesClusterNodeDetail: FunctionComponent<
if (nodeObject) {
const nodeStatus: { label: string; isReady: boolean } = getNodeStatus();
// Extract node roles from labels
const roles: Array<string> = Object.keys(
nodeObject.metadata.labels,
)
.filter((key: string) => {
return key.startsWith("node-role.kubernetes.io/");
})
.map((key: string) => {
return key.replace("node-role.kubernetes.io/", "");
});
// Extract internal IP
const internalIP: string =
nodeObject.status.addresses.find(
(a: { type: string; address: string }) => {
return a.type === "InternalIP";
},
)?.address || "N/A";
// Check pressure conditions
const pressureConditions: Array<string> = nodeObject.status.conditions
.filter((c: KubernetesCondition) => {
return (
c.status === "True" &&
(c.type === "MemoryPressure" ||
c.type === "DiskPressure" ||
c.type === "PIDPressure")
);
})
.map((c: KubernetesCondition) => {
return c.type;
});
summaryFields.push({
title: "Status",
value: (
<StatusBadge
text={nodeStatus.label}
type={
nodeStatus.isReady
? StatusBadgeType.Success
: StatusBadgeType.Danger
}
/>
),
});
if (roles.length > 0) {
summaryFields.push({
title: "Roles",
value: (
<div className="flex gap-1 flex-wrap">
{roles.map((role: string) => {
return (
<StatusBadge
key={role}
text={role}
type={StatusBadgeType.Info}
/>
);
})}
</div>
),
});
}
summaryFields.push({ title: "Internal IP", value: internalIP });
if (pressureConditions.length > 0) {
summaryFields.push({
title: "Pressure",
value: (
<div className="flex gap-1 flex-wrap">
{pressureConditions.map((p: string) => {
return (
<StatusBadge
key={p}
text={p}
type={StatusBadgeType.Danger}
/>
);
})}
</div>
),
});
}
summaryFields.push(
{
title: "Status",
value: (
<span
className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${
nodeStatus.isReady
? "bg-green-50 text-green-700"
: "bg-red-50 text-red-700"
}`}
>
{nodeStatus.label}
</span>
),
title: "CPU (Capacity / Allocatable)",
value: `${nodeObject.status.capacity["cpu"] || "N/A"} / ${nodeObject.status.allocatable["cpu"] || "N/A"}`,
},
{
title: "Memory (Capacity / Allocatable)",
value: `${nodeObject.status.capacity["memory"] || "N/A"} / ${nodeObject.status.allocatable["memory"] || "N/A"}`,
},
{
title: "Pods (Capacity)",
value: nodeObject.status.capacity["pods"] || "N/A",
},
{
title: "OS Image",
value: nodeObject.status.nodeInfo.osImage || "N/A",
},
{
title: "Kernel",
value: nodeObject.status.nodeInfo.kernelVersion || "N/A",
},
{
title: "Container Runtime",
value: nodeObject.status.nodeInfo.containerRuntimeVersion || "N/A",
@@ -286,19 +375,19 @@ const KubernetesClusterNodeDetail: FunctionComponent<
},
{
title: "Architecture",
value: nodeObject.status.nodeInfo.architecture || "N/A",
value: `${nodeObject.status.nodeInfo.operatingSystem || "N/A"}/${nodeObject.status.nodeInfo.architecture || "N/A"}`,
},
{
title: "CPU Allocatable",
value: nodeObject.status.allocatable["cpu"] || "N/A",
},
{
title: "Memory Allocatable",
value: nodeObject.status.allocatable["memory"] || "N/A",
title: "Kernel",
value: nodeObject.status.nodeInfo.kernelVersion || "N/A",
},
{
title: "Created",
value: nodeObject.metadata.creationTimestamp || "N/A",
value: nodeObject.metadata.creationTimestamp
? KubernetesResourceUtils.formatAge(
nodeObject.metadata.creationTimestamp,
)
: "N/A",
},
);
}
@@ -350,20 +439,19 @@ const KubernetesClusterNodeDetail: FunctionComponent<
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="nodes"
resourceName={nodeName}
/>
),
},
];
return (
<Fragment>
<div className="mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="Node Name" value={nodeName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
</div>
</div>
<Tabs tabs={tabs} onTabChange={() => {}} />
</Fragment>
);
return <Tabs tabs={tabs} onTabChange={() => {}} />;
};
export default KubernetesClusterNodeDetail;

View File

@@ -6,13 +6,7 @@ import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesRe
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import React, { FunctionComponent, ReactElement, useEffect, useState } from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
@@ -21,6 +15,11 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
import {
fetchK8sObjectsBatch,
KubernetesObjectType,
} from "../Utils/KubernetesObjectFetcher";
import { KubernetesNodeObject } from "../Utils/KubernetesObjectParser";
const KubernetesClusterNodes: FunctionComponent<
PageComponentProps
@@ -48,14 +47,43 @@ const KubernetesClusterNodes: FunctionComponent<
return;
}
const nodeList: Array<KubernetesResource> =
await KubernetesResourceUtils.fetchResourceListWithMemory({
const [nodeList, nodeObjects]: [
Array<KubernetesResource>,
Map<string, KubernetesObjectType>,
] = await Promise.all([
KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: cluster.clusterIdentifier,
metricName: "k8s.node.cpu.utilization",
memoryMetricName: "k8s.node.memory.usage",
resourceNameAttribute: "resource.k8s.node.name",
namespaceAttribute: "resource.k8s.node.name",
});
}),
fetchK8sObjectsBatch({
clusterIdentifier: cluster.clusterIdentifier,
resourceType: "nodes",
}),
]);
for (const resource of nodeList) {
const key: string = resource.name;
const nodeObj: KubernetesObjectType | undefined = nodeObjects.get(key);
if (nodeObj) {
const node: KubernetesNodeObject = nodeObj as KubernetesNodeObject;
// Check conditions for Ready status
const readyCondition = node.status.conditions.find(
(c) => c.type === "Ready",
);
resource.status =
readyCondition && readyCondition.status === "True"
? "Ready"
: "NotReady";
resource.age = KubernetesResourceUtils.formatAge(
node.metadata.creationTimestamp,
);
}
}
setResources(nodeList);
} catch (err) {
@@ -79,23 +107,21 @@ const KubernetesClusterNodes: FunctionComponent<
}
return (
<Fragment>
<KubernetesResourceTable
title="Nodes"
description="All nodes in this cluster with their current resource usage."
resources={resources}
showNamespace={false}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
</Fragment>
<KubernetesResourceTable
title="Nodes"
description="All nodes in this cluster with their current resource usage."
resources={resources}
showNamespace={false}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
);
};

View File

@@ -0,0 +1,218 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import Tabs from "Common/UI/Components/Tabs/Tabs";
import { Tab } from "Common/UI/Components/Tabs/Tab";
import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOverviewTab";
import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab";
import { KubernetesPVCObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
import KubernetesYamlTab from "../../../Components/Kubernetes/KubernetesYamlTab";
import StatusBadge, {
StatusBadgeType,
} from "Common/UI/Components/StatusBadge/StatusBadge";
import KubernetesResourceLink from "../../../Components/Kubernetes/KubernetesResourceLink";
const KubernetesClusterPVCDetail: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(2);
const pvcName: string = Navigation.getLastParamAsString();
const [cluster, setCluster] = useState<KubernetesCluster | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [pvcObject, setPvcObject] = useState<KubernetesPVCObject | null>(null);
const [isLoadingObject, setIsLoadingObject] = useState<boolean>(true);
const fetchCluster: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const item: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
setCluster(item);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchCluster().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
useEffect(() => {
if (!cluster?.clusterIdentifier) {
return;
}
const fetchPvcObject: () => Promise<void> = async (): Promise<void> => {
setIsLoadingObject(true);
try {
const obj: KubernetesPVCObject | null =
await fetchLatestK8sObject<KubernetesPVCObject>({
clusterIdentifier: cluster.clusterIdentifier || "",
resourceType: "persistentvolumeclaims",
resourceName: pvcName,
});
setPvcObject(obj);
} catch {
// Graceful degradation
}
setIsLoadingObject(false);
};
fetchPvcObject().catch(() => {});
}, [cluster?.clusterIdentifier, pvcName]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (!cluster) {
return <ErrorMessage message="Cluster not found." />;
}
const clusterIdentifier: string = cluster.clusterIdentifier || "";
const summaryFields: Array<{
title: string;
value: string | ReactElement;
}> = [
{ title: "PVC Name", value: pvcName },
{ title: "Cluster", value: clusterIdentifier },
];
if (pvcObject) {
summaryFields.push(
{
title: "Namespace",
value: pvcObject.metadata.namespace ? (
<KubernetesResourceLink
modelId={modelId}
resourceKind="Namespace"
resourceName={pvcObject.metadata.namespace}
/>
) : (
"default"
),
},
{
title: "Status",
value: (
<StatusBadge
text={pvcObject.status.phase || "Unknown"}
type={
pvcObject.status.phase === "Bound"
? StatusBadgeType.Success
: pvcObject.status.phase === "Pending"
? StatusBadgeType.Warning
: StatusBadgeType.Danger
}
/>
),
},
{
title: "Storage Class",
value: pvcObject.spec.storageClassName || "N/A",
},
{
title: "Capacity",
value: pvcObject.status.capacity.storage || "N/A",
},
{
title: "Requested Storage",
value: pvcObject.spec.resources.requests.storage || "N/A",
},
{
title: "Volume Name",
value: pvcObject.spec.volumeName ? (
<KubernetesResourceLink
modelId={modelId}
resourceKind="PersistentVolume"
resourceName={pvcObject.spec.volumeName}
/>
) : (
"N/A"
),
},
{
title: "Access Modes",
value: pvcObject.spec.accessModes.join(", ") || "N/A",
},
{
title: "Created",
value: pvcObject.metadata.creationTimestamp || "N/A",
},
);
}
const tabs: Array<Tab> = [
{
name: "Overview",
children: (
<KubernetesOverviewTab
summaryFields={summaryFields}
labels={pvcObject?.metadata.labels || {}}
annotations={pvcObject?.metadata.annotations || {}}
isLoading={isLoadingObject}
/>
),
},
{
name: "Events",
children: (
<Card
title="PVC Events"
description="Kubernetes events for this PVC in the last 24 hours."
>
<KubernetesEventsTab
clusterIdentifier={clusterIdentifier}
resourceKind="PersistentVolumeClaim"
resourceName={pvcName}
namespace={pvcObject?.metadata.namespace}
/>
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="persistentvolumeclaims"
resourceName={pvcName}
namespace={pvcObject?.metadata.namespace}
/>
),
},
];
return <Tabs tabs={tabs} onTabChange={() => {}} />;
};
export default KubernetesClusterPVCDetail;

View File

@@ -0,0 +1,208 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import Tabs from "Common/UI/Components/Tabs/Tabs";
import { Tab } from "Common/UI/Components/Tabs/Tab";
import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOverviewTab";
import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab";
import { KubernetesPVObject } from "../Utils/KubernetesObjectParser";
import { fetchLatestK8sObject } from "../Utils/KubernetesObjectFetcher";
import KubernetesYamlTab from "../../../Components/Kubernetes/KubernetesYamlTab";
import StatusBadge, {
StatusBadgeType,
} from "Common/UI/Components/StatusBadge/StatusBadge";
import KubernetesResourceLink from "../../../Components/Kubernetes/KubernetesResourceLink";
const KubernetesClusterPVDetail: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(2);
const pvName: string = Navigation.getLastParamAsString();
const [cluster, setCluster] = useState<KubernetesCluster | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [pvObject, setPvObject] = useState<KubernetesPVObject | null>(null);
const [isLoadingObject, setIsLoadingObject] = useState<boolean>(true);
const fetchCluster: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const item: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
setCluster(item);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchCluster().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
useEffect(() => {
if (!cluster?.clusterIdentifier) {
return;
}
const fetchPvObject: () => Promise<void> = async (): Promise<void> => {
setIsLoadingObject(true);
try {
const obj: KubernetesPVObject | null =
await fetchLatestK8sObject<KubernetesPVObject>({
clusterIdentifier: cluster.clusterIdentifier || "",
resourceType: "persistentvolumes",
resourceName: pvName,
});
setPvObject(obj);
} catch {
// Graceful degradation
}
setIsLoadingObject(false);
};
fetchPvObject().catch(() => {});
}, [cluster?.clusterIdentifier, pvName]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (!cluster) {
return <ErrorMessage message="Cluster not found." />;
}
const clusterIdentifier: string = cluster.clusterIdentifier || "";
const summaryFields: Array<{
title: string;
value: string | ReactElement;
}> = [
{ title: "PV Name", value: pvName },
{ title: "Cluster", value: clusterIdentifier },
];
if (pvObject) {
summaryFields.push(
{
title: "Status",
value: (
<StatusBadge
text={pvObject.status.phase || "Unknown"}
type={
pvObject.status.phase === "Bound" ||
pvObject.status.phase === "Available"
? StatusBadgeType.Success
: pvObject.status.phase === "Released"
? StatusBadgeType.Warning
: StatusBadgeType.Danger
}
/>
),
},
{
title: "Capacity",
value: pvObject.spec.capacity.storage || "N/A",
},
{
title: "Storage Class",
value: pvObject.spec.storageClassName || "N/A",
},
{
title: "Reclaim Policy",
value: pvObject.spec.persistentVolumeReclaimPolicy || "N/A",
},
{
title: "Access Modes",
value: pvObject.spec.accessModes.join(", ") || "N/A",
},
{
title: "Claim",
value: pvObject.spec.claimRef.name ? (
<span>
{pvObject.spec.claimRef.namespace}/
<KubernetesResourceLink
modelId={modelId}
resourceKind="PersistentVolumeClaim"
resourceName={pvObject.spec.claimRef.name}
/>
</span>
) : (
"N/A"
),
},
{
title: "Created",
value: pvObject.metadata.creationTimestamp || "N/A",
},
);
}
const tabs: Array<Tab> = [
{
name: "Overview",
children: (
<KubernetesOverviewTab
summaryFields={summaryFields}
labels={pvObject?.metadata.labels || {}}
annotations={pvObject?.metadata.annotations || {}}
isLoading={isLoadingObject}
/>
),
},
{
name: "Events",
children: (
<Card
title="PV Events"
description="Kubernetes events for this PV in the last 24 hours."
>
<KubernetesEventsTab
clusterIdentifier={clusterIdentifier}
resourceKind="PersistentVolume"
resourceName={pvName}
/>
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="persistentvolumes"
resourceName={pvName}
/>
),
},
];
return <Tabs tabs={tabs} onTabChange={() => {}} />;
};
export default KubernetesClusterPVDetail;

View File

@@ -0,0 +1,143 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable";
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
import {
fetchK8sObjectsBatch,
KubernetesObjectType,
} from "../Utils/KubernetesObjectFetcher";
import { KubernetesPVCObject } from "../Utils/KubernetesObjectParser";
const KubernetesClusterPVCs: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [resources, setResources] = useState<Array<KubernetesResource>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const fetchData: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const cluster: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
if (!cluster?.clusterIdentifier) {
setError("Cluster not found.");
setIsLoading(false);
return;
}
const pvcObjects: Map<string, KubernetesObjectType> =
await fetchK8sObjectsBatch({
clusterIdentifier: cluster.clusterIdentifier,
resourceType: "persistentvolumeclaims",
});
const pvcResources: Array<KubernetesResource> = [];
for (const pvcObj of pvcObjects.values()) {
const pvc: KubernetesPVCObject = pvcObj as KubernetesPVCObject;
pvcResources.push({
name: pvc.metadata.name,
namespace: pvc.metadata.namespace || "default",
cpuUtilization: null,
memoryUsageBytes: null,
memoryLimitBytes: null,
status: pvc.status.phase || "Unknown",
age: KubernetesResourceUtils.formatAge(
pvc.metadata.creationTimestamp,
),
additionalAttributes: {
storageClass: pvc.spec.storageClassName || "N/A",
capacity: pvc.status.capacity.storage || "N/A",
volumeName: pvc.spec.volumeName || "N/A",
accessModes: pvc.spec.accessModes.join(", ") || "N/A",
},
});
}
setResources(pvcResources);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchData().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
return (
<KubernetesResourceTable
title="Persistent Volume Claims"
description="All PVCs in this cluster with their current status."
resources={resources}
showResourceMetrics={false}
columns={[
{
title: "Storage Class",
key: "storageClass",
},
{
title: "Capacity",
key: "capacity",
},
{
title: "Volume",
key: "volumeName",
},
{
title: "Access Modes",
key: "accessModes",
},
]}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_PVC_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
emptyMessage="No PVCs found. PVC data will appear here once the kubernetes-agent Helm chart has resourceSpecs.enabled set to true and includes persistentvolumeclaims."
/>
);
};
export default KubernetesClusterPVCs;

View File

@@ -0,0 +1,134 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable";
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import {
fetchK8sObjectsBatch,
KubernetesObjectType,
} from "../Utils/KubernetesObjectFetcher";
import { KubernetesPVObject } from "../Utils/KubernetesObjectParser";
const KubernetesClusterPVs: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [resources, setResources] = useState<Array<KubernetesResource>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const fetchData: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const cluster: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
if (!cluster?.clusterIdentifier) {
setError("Cluster not found.");
setIsLoading(false);
return;
}
const pvObjects: Map<string, KubernetesObjectType> =
await fetchK8sObjectsBatch({
clusterIdentifier: cluster.clusterIdentifier,
resourceType: "persistentvolumes",
});
const pvResources: Array<KubernetesResource> = [];
for (const pvObj of pvObjects.values()) {
const pv: KubernetesPVObject = pvObj as KubernetesPVObject;
pvResources.push({
name: pv.metadata.name,
namespace: "",
cpuUtilization: null,
memoryUsageBytes: null,
memoryLimitBytes: null,
status: pv.status.phase || "Unknown",
age: KubernetesResourceUtils.formatAge(
pv.metadata.creationTimestamp,
),
additionalAttributes: {
capacity: pv.spec.capacity.storage || "N/A",
storageClass: pv.spec.storageClassName || "N/A",
reclaimPolicy: pv.spec.persistentVolumeReclaimPolicy || "N/A",
claimRef: pv.spec.claimRef.name
? `${pv.spec.claimRef.namespace}/${pv.spec.claimRef.name}`
: "N/A",
},
});
}
setResources(pvResources);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchData().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
return (
<KubernetesResourceTable
title="Persistent Volumes"
description="All PVs in this cluster with their current status."
resources={resources}
showNamespace={false}
showResourceMetrics={false}
columns={[
{
title: "Capacity",
key: "capacity",
},
{
title: "Storage Class",
key: "storageClass",
},
{
title: "Reclaim Policy",
key: "reclaimPolicy",
},
{
title: "Claim",
key: "claimRef",
},
]}
emptyMessage="No PVs found. PV data will appear here once the kubernetes-agent Helm chart has resourceSpecs.enabled set to true and includes persistentvolumes."
/>
);
};
export default KubernetesClusterPVs;

View File

@@ -3,13 +3,11 @@ import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import MetricQueryConfigData, {
ChartSeries,
} from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
@@ -28,8 +26,16 @@ import KubernetesContainersTab from "../../../Components/Kubernetes/KubernetesCo
import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab";
import KubernetesLogsTab from "../../../Components/Kubernetes/KubernetesLogsTab";
import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab";
import KubernetesEnvVarsTab from "../../../Components/Kubernetes/KubernetesEnvVarsTab";
import KubernetesVolumeMountsTab from "../../../Components/Kubernetes/KubernetesVolumeMountsTab";
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";
import KubernetesResourceLink from "../../../Components/Kubernetes/KubernetesResourceLink";
const KubernetesClusterPodDetail: FunctionComponent<
PageComponentProps
@@ -147,7 +153,7 @@ const KubernetesClusterPodDetail: FunctionComponent<
title: "Container Memory Usage",
description: `Memory usage for containers in pod ${podName}`,
legend: "Memory",
legendUnit: "bytes",
legendUnit: "",
},
metricQueryData: {
filterData: {
@@ -164,6 +170,7 @@ const KubernetesClusterPodDetail: FunctionComponent<
},
},
getSeries: getContainerSeries,
yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart,
};
const podCpuQuery: MetricQueryConfigData = {
@@ -196,7 +203,7 @@ const KubernetesClusterPodDetail: FunctionComponent<
title: "Pod Memory Usage",
description: `Memory usage for pod ${podName}`,
legend: "Memory",
legendUnit: "bytes",
legendUnit: "",
},
metricQueryData: {
filterData: {
@@ -212,6 +219,7 @@ const KubernetesClusterPodDetail: FunctionComponent<
attributes: true,
},
},
yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart,
};
// Build overview summary fields from pod object
@@ -222,39 +230,110 @@ const KubernetesClusterPodDetail: FunctionComponent<
];
if (podObject) {
// Compute restart count
const restartCount: number = podObject.status.containerStatuses.reduce(
(sum: number, cs: { restartCount: number }) => {
return sum + cs.restartCount;
},
0,
);
// Compute container images
const containerImages: Array<string> = podObject.spec.containers.map(
(c: { image: string }) => {
return c.image;
},
);
summaryFields.push(
{
title: "Namespace",
value: podObject.metadata.namespace || "default",
value: podObject.metadata.namespace ? (
<KubernetesResourceLink
modelId={modelId}
resourceKind="Namespace"
resourceName={podObject.metadata.namespace}
/>
) : (
"default"
),
},
{
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
}
/>
),
},
{
title: "QoS Class",
value: podObject.status.qosClass || "N/A",
},
{
title: "Restarts",
value: (
<StatusBadge
text={restartCount.toString()}
type={
restartCount > 0
? StatusBadgeType.Warning
: StatusBadgeType.Neutral
}
/>
),
},
{
title: "Node",
value: podObject.spec.nodeName ? (
<KubernetesResourceLink
modelId={modelId}
resourceKind="Node"
resourceName={podObject.spec.nodeName}
/>
) : (
"N/A"
),
},
{ title: "Node", value: podObject.spec.nodeName || "N/A" },
{ title: "Pod IP", value: podObject.status.podIP || "N/A" },
{ title: "Host IP", value: podObject.status.hostIP || "N/A" },
{
title: "Service Account",
value: podObject.spec.serviceAccountName || "default",
},
{
title: "Images",
value: (
<div className="space-y-1">
{containerImages.map((img: string, idx: number) => {
return (
<div
key={idx}
className="text-xs font-mono bg-gray-50 px-2 py-1 rounded"
>
{img}
</div>
);
})}
</div>
),
},
{
title: "Created",
value: podObject.metadata.creationTimestamp || "N/A",
value: podObject.metadata.creationTimestamp
? KubernetesResourceUtils.formatAge(
podObject.metadata.creationTimestamp,
)
: "N/A",
},
);
}
@@ -269,6 +348,7 @@ const KubernetesClusterPodDetail: FunctionComponent<
annotations={podObject?.metadata.annotations || {}}
conditions={podObject?.status.conditions}
ownerReferences={podObject?.metadata.ownerReferences}
modelId={modelId}
isLoading={isLoadingObject}
/>
),
@@ -291,6 +371,38 @@ const KubernetesClusterPodDetail: FunctionComponent<
</div>
),
},
{
name: "Env Vars",
children: podObject ? (
<KubernetesEnvVarsTab
containers={podObject.spec.containers}
initContainers={podObject.spec.initContainers}
/>
) : isLoadingObject ? (
<PageLoader isVisible={true} />
) : (
<div className="text-gray-500 text-sm p-4">
Environment variable details not yet available. Ensure the
kubernetes-agent Helm chart has resourceSpecs.enabled set to true.
</div>
),
},
{
name: "Volume Mounts",
children: podObject ? (
<KubernetesVolumeMountsTab
containers={podObject.spec.containers}
initContainers={podObject.spec.initContainers}
/>
) : isLoadingObject ? (
<PageLoader isVisible={true} />
) : (
<div className="text-gray-500 text-sm p-4">
Volume mount details not yet available. Ensure the kubernetes-agent
Helm chart has resourceSpecs.enabled set to true.
</div>
),
},
{
name: "Events",
children: (
@@ -310,16 +422,11 @@ const KubernetesClusterPodDetail: FunctionComponent<
{
name: "Logs",
children: (
<Card
title="Application Logs"
description="Container logs for this pod from the last 6 hours."
>
<KubernetesLogsTab
clusterIdentifier={clusterIdentifier}
podName={podName}
namespace={podObject?.metadata.namespace}
/>
</Card>
<KubernetesLogsTab
clusterIdentifier={clusterIdentifier}
podName={podName}
namespace={podObject?.metadata.namespace}
/>
),
},
{
@@ -335,20 +442,20 @@ const KubernetesClusterPodDetail: FunctionComponent<
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="pods"
resourceName={podName}
namespace={podObject?.metadata.namespace}
/>
),
},
];
return (
<Fragment>
<div className="mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="Pod Name" value={podName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
</div>
</div>
<Tabs tabs={tabs} onTabChange={() => {}} />
</Fragment>
);
return <Tabs tabs={tabs} onTabChange={() => {}} />;
};
export default KubernetesClusterPodDetail;

View File

@@ -6,13 +6,7 @@ import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesRe
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import React, { FunctionComponent, ReactElement, useEffect, useState } from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
@@ -21,6 +15,37 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
import {
fetchK8sObjectsBatch,
KubernetesObjectType,
} from "../Utils/KubernetesObjectFetcher";
import { KubernetesPodObject } from "../Utils/KubernetesObjectParser";
function parseMemoryString(memory: string): number {
if (!memory) {
return 0;
}
const value: number = parseFloat(memory);
if (memory.endsWith("Gi")) {
return value * 1024 * 1024 * 1024;
}
if (memory.endsWith("Mi")) {
return value * 1024 * 1024;
}
if (memory.endsWith("Ki")) {
return value * 1024;
}
if (memory.endsWith("G")) {
return value * 1000 * 1000 * 1000;
}
if (memory.endsWith("M")) {
return value * 1000 * 1000;
}
if (memory.endsWith("K")) {
return value * 1000;
}
return value;
}
const KubernetesClusterPods: FunctionComponent<
PageComponentProps
@@ -48,8 +73,11 @@ const KubernetesClusterPods: FunctionComponent<
return;
}
const podList: Array<KubernetesResource> =
await KubernetesResourceUtils.fetchResourceListWithMemory({
const [podList, podObjects]: [
Array<KubernetesResource>,
Map<string, KubernetesObjectType>,
] = await Promise.all([
KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: cluster.clusterIdentifier,
metricName: "k8s.pod.cpu.utilization",
memoryMetricName: "k8s.pod.memory.usage",
@@ -58,7 +86,46 @@ const KubernetesClusterPods: FunctionComponent<
"resource.k8s.node.name",
"resource.k8s.deployment.name",
],
});
}),
fetchK8sObjectsBatch({
clusterIdentifier: cluster.clusterIdentifier,
resourceType: "pods",
}),
]);
for (const resource of podList) {
const key: string = `${resource.namespace}/${resource.name}`;
const podObj: KubernetesObjectType | undefined = podObjects.get(key);
if (podObj) {
const pod: KubernetesPodObject = podObj as KubernetesPodObject;
resource.status = pod.status.phase || "Unknown";
for (const cs of pod.status.containerStatuses) {
if (cs.state === "waiting" && cs.reason) {
resource.status = cs.reason;
break;
}
}
resource.age = KubernetesResourceUtils.formatAge(
pod.metadata.creationTimestamp,
);
resource.additionalAttributes["containers"] =
`${pod.spec.containers.length}`;
let totalMemoryLimit: number = 0;
for (const container of pod.spec.containers) {
if (container.resources.limits["memory"]) {
totalMemoryLimit += parseMemoryString(
container.resources.limits["memory"],
);
}
}
if (totalMemoryLimit > 0) {
resource.memoryLimitBytes = totalMemoryLimit;
}
}
}
setResources(podList);
} catch (err) {
@@ -82,28 +149,30 @@ const KubernetesClusterPods: FunctionComponent<
}
return (
<Fragment>
<KubernetesResourceTable
title="Pods"
description="All pods running in this cluster with their current resource usage."
resources={resources}
columns={[
<KubernetesResourceTable
title="Pods"
description="All pods running in this cluster with their current resource usage."
resources={resources}
columns={[
{
title: "Node",
key: "resource.k8s.node.name",
},
{
title: "Containers",
key: "containers",
},
]}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL] as Route,
{
title: "Node",
key: "resource.k8s.node.name",
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
]}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_POD_DETAIL] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
</Fragment>
);
}}
/>
);
};

View File

@@ -0,0 +1,272 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import MetricView from "../../../Components/Metrics/MetricView";
import MetricViewData from "Common/Types/Metrics/MetricViewData";
import MetricQueryConfigData from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import OneUptimeDate from "Common/Types/Date";
import InBetween from "Common/Types/BaseDatabase/InBetween";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
const KubernetesClusterServiceMesh: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [cluster, setCluster] = useState<KubernetesCluster | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [istioRequestsMetricViewData, setIstioRequestsMetricViewData] =
useState<MetricViewData | null>(null);
const [istioLatencyMetricViewData, setIstioLatencyMetricViewData] =
useState<MetricViewData | null>(null);
const [linkerdRequestsMetricViewData, setLinkerdRequestsMetricViewData] =
useState<MetricViewData | null>(null);
const [linkerdLatencyMetricViewData, setLinkerdLatencyMetricViewData] =
useState<MetricViewData | null>(null);
const fetchCluster: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const item: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
setCluster(item);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchCluster().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
useEffect(() => {
if (!cluster) {
return;
}
const clusterIdentifier: string = cluster.clusterIdentifier || "";
const endDate: Date = OneUptimeDate.getCurrentDate();
const startDate: Date = OneUptimeDate.addRemoveHours(endDate, -6);
const startAndEndDate: InBetween<Date> = new InBetween(startDate, endDate);
// Istio metrics
const istioRequestsTotalQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "istio_requests_total",
title: "Istio Request Rate",
description: "Total requests through the Istio service mesh",
legend: "Requests",
legendUnit: "req/s",
},
metricQueryData: {
filterData: {
metricName: "istio_requests_total",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
},
aggegationType: AggregationType.Sum,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
};
const istioRequestDurationQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "istio_request_duration",
title: "Istio Request Latency",
description:
"Request duration through the Istio service mesh (p50/p99)",
legend: "Latency",
legendUnit: "ms",
},
metricQueryData: {
filterData: {
metricName: "istio_request_duration_milliseconds_bucket",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
};
// Linkerd metrics
const linkerdRequestTotalQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "linkerd_request_total",
title: "Linkerd Request Rate",
description: "Total requests through the Linkerd service mesh",
legend: "Requests",
legendUnit: "req/s",
},
metricQueryData: {
filterData: {
metricName: "request_total",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
},
aggegationType: AggregationType.Sum,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
};
const linkerdResponseLatencyQuery: MetricQueryConfigData = {
metricAliasData: {
metricVariable: "linkerd_response_latency",
title: "Linkerd Response Latency",
description:
"Response latency through the Linkerd service mesh (p50/p99)",
legend: "Latency",
legendUnit: "ms",
},
metricQueryData: {
filterData: {
metricName: "response_latency_ms_bucket",
attributes: {
"resource.k8s.cluster.name": clusterIdentifier,
},
aggegationType: AggregationType.Avg,
aggregateBy: {},
},
groupBy: {
attributes: true,
},
},
};
setIstioRequestsMetricViewData({
startAndEndDate: startAndEndDate,
queryConfigs: [istioRequestsTotalQuery],
formulaConfigs: [],
});
setIstioLatencyMetricViewData({
startAndEndDate: startAndEndDate,
queryConfigs: [istioRequestDurationQuery],
formulaConfigs: [],
});
setLinkerdRequestsMetricViewData({
startAndEndDate: startAndEndDate,
queryConfigs: [linkerdRequestTotalQuery],
formulaConfigs: [],
});
setLinkerdLatencyMetricViewData({
startAndEndDate: startAndEndDate,
queryConfigs: [linkerdResponseLatencyQuery],
formulaConfigs: [],
});
}, [cluster]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (
!cluster ||
!istioRequestsMetricViewData ||
!istioLatencyMetricViewData ||
!linkerdRequestsMetricViewData ||
!linkerdLatencyMetricViewData
) {
return <ErrorMessage message="Cluster not found." />;
}
return (
<Fragment>
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-700">
Service mesh metrics require the <code>serviceMesh.enabled</code> flag
to be set to <code>true</code> and the <code>serviceMesh.provider</code>{" "}
to be configured in the kubernetes-agent Helm chart values. Supported
providers are Istio and Linkerd.
</p>
</div>
<Card
title="Istio - Request Rate"
description="Total request rate through Istio envoy sidecars across all services in the mesh."
>
<MetricView
data={istioRequestsMetricViewData}
hideQueryElements={true}
onChange={() => {}}
/>
</Card>
<Card
title="Istio - Request Latency"
description="Request duration distribution through the Istio service mesh."
>
<MetricView
data={istioLatencyMetricViewData}
hideQueryElements={true}
onChange={() => {}}
/>
</Card>
<Card
title="Linkerd - Request Rate"
description="Total request rate through Linkerd proxy sidecars across all services in the mesh."
>
<MetricView
data={linkerdRequestsMetricViewData}
hideQueryElements={true}
onChange={() => {}}
/>
</Card>
<Card
title="Linkerd - Response Latency"
description="Response latency distribution through the Linkerd service mesh."
>
<MetricView
data={linkerdLatencyMetricViewData}
hideQueryElements={true}
onChange={() => {}}
/>
</Card>
</Fragment>
);
};
export default KubernetesClusterServiceMesh;

View File

@@ -8,13 +8,32 @@ import SideMenuItem from "Common/UI/Components/SideMenu/SideMenuItem";
import SideMenuSection from "Common/UI/Components/SideMenu/SideMenuSection";
import React, { FunctionComponent, ReactElement } from "react";
export interface ResourceCounts {
namespaces?: number | undefined;
pods?: number | undefined;
deployments?: number | undefined;
statefulSets?: number | undefined;
daemonSets?: number | undefined;
jobs?: number | undefined;
cronJobs?: number | undefined;
nodes?: number | undefined;
containers?: number | undefined;
pvcs?: number | undefined;
pvs?: number | undefined;
hpas?: number | undefined;
vpas?: number | undefined;
}
export interface ComponentProps {
modelId: ObjectID;
resourceCounts?: ResourceCounts | undefined;
}
const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const counts: ResourceCounts = props.resourceCounts || {};
return (
<SideMenu>
<SideMenuSection title="Basic">
@@ -50,6 +69,7 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Folder}
badge={counts.namespaces}
/>
<SideMenuItem
link={{
@@ -60,6 +80,7 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Circle}
badge={counts.pods}
/>
<SideMenuItem
link={{
@@ -70,6 +91,7 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Layers}
badge={counts.deployments}
/>
<SideMenuItem
link={{
@@ -80,6 +102,7 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Database}
badge={counts.statefulSets}
/>
<SideMenuItem
link={{
@@ -90,6 +113,7 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Settings}
badge={counts.daemonSets}
/>
<SideMenuItem
link={{
@@ -100,6 +124,7 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Play}
badge={counts.jobs}
/>
<SideMenuItem
link={{
@@ -110,6 +135,7 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Clock}
badge={counts.cronJobs}
/>
</SideMenuSection>
@@ -123,6 +149,7 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Server}
badge={counts.nodes}
/>
<SideMenuItem
link={{
@@ -133,6 +160,58 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
),
}}
icon={IconProp.Cube}
badge={counts.containers}
/>
<SideMenuItem
link={{
title: "PVCs",
to: RouteUtil.populateRouteParams(
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_PVCS
] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Disc}
badge={counts.pvcs}
/>
<SideMenuItem
link={{
title: "PVs",
to: RouteUtil.populateRouteParams(
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_PVS
] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Disc}
badge={counts.pvs}
/>
</SideMenuSection>
<SideMenuSection title="Scaling">
<SideMenuItem
link={{
title: "HPAs",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_HPAS] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.ArrowUpDown}
badge={counts.hpas}
/>
<SideMenuItem
link={{
title: "VPAs",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_VPAS] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Scale}
badge={counts.vpas}
/>
</SideMenuSection>
@@ -157,6 +236,16 @@ const KubernetesClusterSideMenu: FunctionComponent<ComponentProps> = (
}}
icon={IconProp.Activity}
/>
<SideMenuItem
link={{
title: "Service Mesh",
to: RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_SERVICE_MESH] as Route,
{ modelId: props.modelId },
),
}}
icon={IconProp.Globe}
/>
</SideMenuSection>
<SideMenuSection title="Advanced">

View File

@@ -3,13 +3,12 @@ import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import InfoCard from "Common/UI/Components/InfoCard/InfoCard";
import MetricQueryConfigData, {
ChartSeries,
} from "Common/Types/Metrics/MetricQueryConfigData";
import AggregationType from "Common/Types/BaseDatabase/AggregationType";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
@@ -28,6 +27,12 @@ import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEvents
import KubernetesMetricsTab from "../../../Components/Kubernetes/KubernetesMetricsTab";
import { KubernetesStatefulSetObject } 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";
import KubernetesResourceLink from "../../../Components/Kubernetes/KubernetesResourceLink";
const KubernetesClusterStatefulSetDetail: FunctionComponent<
PageComponentProps
@@ -145,7 +150,7 @@ const KubernetesClusterStatefulSetDetail: FunctionComponent<
title: "Pod Memory Usage",
description: `Memory usage for pods in statefulset ${statefulSetName}`,
legend: "Memory",
legendUnit: "bytes",
legendUnit: "",
},
metricQueryData: {
filterData: {
@@ -162,6 +167,7 @@ const KubernetesClusterStatefulSetDetail: FunctionComponent<
},
},
getSeries: getSeries,
yAxisValueFormatter: KubernetesResourceUtils.formatBytesForChart,
};
// Build overview summary fields from statefulset object
@@ -175,7 +181,15 @@ const KubernetesClusterStatefulSetDetail: FunctionComponent<
summaryFields.push(
{
title: "Namespace",
value: objectData.metadata.namespace || "default",
value: objectData.metadata.namespace ? (
<KubernetesResourceLink
modelId={modelId}
resourceKind="Namespace"
resourceName={objectData.metadata.namespace}
/>
) : (
"default"
),
},
{
title: "Replicas",
@@ -183,7 +197,19 @@ const KubernetesClusterStatefulSetDetail: FunctionComponent<
},
{
title: "Ready Replicas",
value: String(objectData.status.readyReplicas ?? "N/A"),
value: (
<StatusBadge
text={`${objectData.status.readyReplicas ?? 0}/${objectData.spec.replicas ?? 0}`}
type={
(objectData.status.readyReplicas ?? 0) >=
(objectData.spec.replicas ?? 0)
? StatusBadgeType.Success
: (objectData.status.readyReplicas ?? 0) > 0
? StatusBadgeType.Warning
: StatusBadgeType.Danger
}
/>
),
},
{
title: "Service Name",
@@ -243,20 +269,20 @@ const KubernetesClusterStatefulSetDetail: FunctionComponent<
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="statefulsets"
resourceName={statefulSetName}
namespace={objectData?.metadata.namespace}
/>
),
},
];
return (
<Fragment>
<div className="mb-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<InfoCard title="StatefulSet" value={statefulSetName || "Unknown"} />
<InfoCard title="Cluster" value={clusterIdentifier} />
</div>
</div>
<Tabs tabs={tabs} onTabChange={() => {}} />
</Fragment>
);
return <Tabs tabs={tabs} onTabChange={() => {}} />;
};
export default KubernetesClusterStatefulSetDetail;

View File

@@ -6,13 +6,7 @@ import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesRe
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import React, { FunctionComponent, ReactElement, useEffect, useState } from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
@@ -21,6 +15,11 @@ import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
import {
fetchK8sObjectsBatch,
KubernetesObjectType,
} from "../Utils/KubernetesObjectFetcher";
import { KubernetesStatefulSetObject } from "../Utils/KubernetesObjectParser";
const KubernetesClusterStatefulSets: FunctionComponent<
PageComponentProps
@@ -48,13 +47,46 @@ const KubernetesClusterStatefulSets: FunctionComponent<
return;
}
const statefulsetList: Array<KubernetesResource> =
await KubernetesResourceUtils.fetchResourceListWithMemory({
const [statefulsetList, statefulsetObjects]: [
Array<KubernetesResource>,
Map<string, KubernetesObjectType>,
] = await Promise.all([
KubernetesResourceUtils.fetchResourceListWithMemory({
clusterIdentifier: cluster.clusterIdentifier,
metricName: "k8s.pod.cpu.utilization",
memoryMetricName: "k8s.pod.memory.usage",
resourceNameAttribute: "resource.k8s.statefulset.name",
});
}),
fetchK8sObjectsBatch({
clusterIdentifier: cluster.clusterIdentifier,
resourceType: "statefulsets",
}),
]);
for (const resource of statefulsetList) {
const key: string = `${resource.namespace}/${resource.name}`;
const stsObj: KubernetesObjectType | undefined =
statefulsetObjects.get(key);
if (stsObj) {
const sts: KubernetesStatefulSetObject =
stsObj as KubernetesStatefulSetObject;
const readyReplicas: number = sts.status.readyReplicas;
const replicas: number = sts.spec.replicas;
resource.status =
readyReplicas === replicas && replicas > 0
? "Ready"
: "Progressing";
resource.additionalAttributes["ready"] =
`${readyReplicas}/${replicas}`;
resource.age = KubernetesResourceUtils.formatAge(
sts.metadata.creationTimestamp,
);
}
}
setResources(statefulsetList);
} catch (err) {
@@ -78,24 +110,28 @@ const KubernetesClusterStatefulSets: FunctionComponent<
}
return (
<Fragment>
<KubernetesResourceTable
title="StatefulSets"
description="All statefulsets running in this cluster."
resources={resources}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL
] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
</Fragment>
<KubernetesResourceTable
title="StatefulSets"
description="All statefulsets running in this cluster."
resources={resources}
columns={[
{
title: "Ready",
key: "ready",
},
]}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSET_DETAIL
] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
/>
);
};

View File

@@ -0,0 +1,220 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import Card from "Common/UI/Components/Card/Card";
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import Tabs from "Common/UI/Components/Tabs/Tabs";
import { Tab } from "Common/UI/Components/Tabs/Tab";
import KubernetesOverviewTab from "../../../Components/Kubernetes/KubernetesOverviewTab";
import KubernetesEventsTab from "../../../Components/Kubernetes/KubernetesEventsTab";
import { KubernetesVPAObject } 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";
import KubernetesResourceLink from "../../../Components/Kubernetes/KubernetesResourceLink";
const KubernetesClusterVPADetail: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(2);
const vpaName: string = Navigation.getLastParamAsString();
const [cluster, setCluster] = useState<KubernetesCluster | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const [objectData, setObjectData] =
useState<KubernetesVPAObject | null>(null);
const [isLoadingObject, setIsLoadingObject] = useState<boolean>(true);
const fetchCluster: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const item: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
setCluster(item);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchCluster().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
useEffect(() => {
if (!cluster?.clusterIdentifier) {
return;
}
const fetchObject: () => Promise<void> = async (): Promise<void> => {
setIsLoadingObject(true);
try {
const obj: KubernetesVPAObject | null =
await fetchLatestK8sObject<KubernetesVPAObject>({
clusterIdentifier: cluster.clusterIdentifier || "",
resourceType: "verticalpodautoscalers",
resourceName: vpaName,
});
setObjectData(obj);
} catch {
// Graceful degradation — overview tab shows empty state
}
setIsLoadingObject(false);
};
fetchObject().catch(() => {});
}, [cluster?.clusterIdentifier, vpaName]);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
if (!cluster) {
return <ErrorMessage message="Cluster not found." />;
}
const clusterIdentifier: string = cluster.clusterIdentifier || "";
const summaryFields: Array<{ title: string; value: string | ReactElement }> =
[
{ title: "Name", value: vpaName },
{ title: "Cluster", value: clusterIdentifier },
];
if (objectData) {
const hasRecommendations: boolean =
objectData.status.recommendation.containerRecommendations.length > 0;
summaryFields.push(
{
title: "Namespace",
value: objectData.metadata.namespace ? (
<KubernetesResourceLink
modelId={modelId}
resourceKind="Namespace"
resourceName={objectData.metadata.namespace}
/>
) : (
"default"
),
},
{
title: "Target Kind",
value: objectData.spec.targetRef.kind || "N/A",
},
{
title: "Target Name",
value: objectData.spec.targetRef.name || "N/A",
},
{
title: "Update Mode",
value: objectData.spec.updatePolicy.updateMode || "N/A",
},
{
title: "Status",
value: (
<StatusBadge
text={hasRecommendations ? "Active" : "Pending"}
type={
hasRecommendations
? StatusBadgeType.Success
: StatusBadgeType.Warning
}
/>
),
},
{
title: "Created",
value: objectData.metadata.creationTimestamp
? KubernetesResourceUtils.formatAge(
objectData.metadata.creationTimestamp,
)
: "N/A",
},
);
// Add container recommendations
if (hasRecommendations) {
for (const rec of objectData.status.recommendation
.containerRecommendations) {
const targetCpu: string = rec.target["cpu"] || "N/A";
const targetMemory: string = rec.target["memory"] || "N/A";
summaryFields.push({
title: `Recommendation (${rec.containerName})`,
value: `CPU: ${targetCpu}, Memory: ${targetMemory}`,
});
}
}
}
const tabs: Array<Tab> = [
{
name: "Overview",
children: (
<KubernetesOverviewTab
summaryFields={summaryFields}
labels={objectData?.metadata.labels || {}}
annotations={objectData?.metadata.annotations || {}}
isLoading={isLoadingObject}
/>
),
},
{
name: "Events",
children: (
<Card
title="VPA Events"
description="Kubernetes events for this VPA in the last 24 hours."
>
<KubernetesEventsTab
clusterIdentifier={clusterIdentifier}
resourceKind="VerticalPodAutoscaler"
resourceName={vpaName}
namespace={objectData?.metadata.namespace}
/>
</Card>
),
},
{
name: "YAML",
children: (
<KubernetesYamlTab
clusterIdentifier={clusterIdentifier}
resourceType="verticalpodautoscalers"
resourceName={vpaName}
namespace={objectData?.metadata.namespace}
/>
),
},
];
return <Tabs tabs={tabs} onTabChange={() => {}} />;
};
export default KubernetesClusterVPADetail;

View File

@@ -0,0 +1,140 @@
import PageComponentProps from "../../PageComponentProps";
import ObjectID from "Common/Types/ObjectID";
import Navigation from "Common/UI/Utils/Navigation";
import KubernetesCluster from "Common/Models/DatabaseModels/KubernetesCluster";
import KubernetesResourceTable from "../../../Components/Kubernetes/KubernetesResourceTable";
import {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import KubernetesResourceUtils from "../Utils/KubernetesResourceUtils";
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import API from "Common/UI/Utils/API/API";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import ErrorMessage from "Common/UI/Components/ErrorMessage/ErrorMessage";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import PageMap from "../../../Utils/PageMap";
import RouteMap, { RouteUtil } from "../../../Utils/RouteMap";
import Route from "Common/Types/API/Route";
import {
fetchK8sObjectsBatch,
KubernetesObjectType,
} from "../Utils/KubernetesObjectFetcher";
import { KubernetesVPAObject } from "../Utils/KubernetesObjectParser";
const KubernetesClusterVPAs: FunctionComponent<
PageComponentProps
> = (): ReactElement => {
const modelId: ObjectID = Navigation.getLastParamAsObjectID(1);
const [resources, setResources] = useState<Array<KubernetesResource>>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string>("");
const fetchData: PromiseVoidFunction = async (): Promise<void> => {
setIsLoading(true);
try {
const cluster: KubernetesCluster | null = await ModelAPI.getItem({
modelType: KubernetesCluster,
id: modelId,
select: {
clusterIdentifier: true,
},
});
if (!cluster?.clusterIdentifier) {
setError("Cluster not found.");
setIsLoading(false);
return;
}
const vpaObjects: Map<string, KubernetesObjectType> =
await fetchK8sObjectsBatch({
clusterIdentifier: cluster.clusterIdentifier,
resourceType: "verticalpodautoscalers",
});
const vpaResources: Array<KubernetesResource> = [];
for (const vpaObj of vpaObjects.values()) {
const vpa: KubernetesVPAObject = vpaObj as KubernetesVPAObject;
const hasRecommendations: boolean =
vpa.status.recommendation.containerRecommendations.length > 0;
vpaResources.push({
name: vpa.metadata.name,
namespace: vpa.metadata.namespace || "default",
cpuUtilization: null,
memoryUsageBytes: null,
memoryLimitBytes: null,
status: hasRecommendations ? "Active" : "Pending",
age: KubernetesResourceUtils.formatAge(
vpa.metadata.creationTimestamp,
),
additionalAttributes: {
target: `${vpa.spec.targetRef.kind}/${vpa.spec.targetRef.name}`,
updateMode: vpa.spec.updatePolicy.updateMode || "N/A",
},
});
}
setResources(vpaResources);
} catch (err) {
setError(API.getFriendlyMessage(err));
}
setIsLoading(false);
};
useEffect(() => {
fetchData().catch((err: Error) => {
setError(API.getFriendlyMessage(err));
});
}, []);
if (isLoading) {
return <PageLoader isVisible={true} />;
}
if (error) {
return <ErrorMessage message={error} />;
}
return (
<KubernetesResourceTable
title="Vertical Pod Autoscalers"
description="All VPAs in this cluster with their current status."
resources={resources}
showResourceMetrics={false}
columns={[
{
title: "Target",
key: "target",
},
{
title: "Update Mode",
key: "updateMode",
},
]}
getViewRoute={(resource: KubernetesResource) => {
return RouteUtil.populateRouteParams(
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_VPA_DETAIL
] as Route,
{
modelId: modelId,
subModelId: new ObjectID(resource.name),
},
);
}}
emptyMessage="No VPAs found. VPA data will appear here once the kubernetes-agent Helm chart has resourceSpecs.enabled set to true and includes verticalpodautoscalers."
/>
);
};
export default KubernetesClusterVPAs;

View File

@@ -28,8 +28,17 @@ import KubernetesClusterViewNodes from "../Pages/Kubernetes/View/Nodes";
import KubernetesClusterViewNodeDetail from "../Pages/Kubernetes/View/NodeDetail";
import KubernetesClusterViewContainers from "../Pages/Kubernetes/View/Containers";
import KubernetesClusterViewContainerDetail from "../Pages/Kubernetes/View/ContainerDetail";
import KubernetesClusterViewPVCs from "../Pages/Kubernetes/View/PersistentVolumeClaims";
import KubernetesClusterViewPVCDetail from "../Pages/Kubernetes/View/PVCDetail";
import KubernetesClusterViewPVs from "../Pages/Kubernetes/View/PersistentVolumes";
import KubernetesClusterViewPVDetail from "../Pages/Kubernetes/View/PVDetail";
import KubernetesClusterViewHPAs from "../Pages/Kubernetes/View/HPAs";
import KubernetesClusterViewHPADetail from "../Pages/Kubernetes/View/HPADetail";
import KubernetesClusterViewVPAs from "../Pages/Kubernetes/View/VPAs";
import KubernetesClusterViewVPADetail from "../Pages/Kubernetes/View/VPADetail";
import KubernetesClusterViewEvents from "../Pages/Kubernetes/View/Events";
import KubernetesClusterViewControlPlane from "../Pages/Kubernetes/View/ControlPlane";
import KubernetesClusterViewServiceMesh from "../Pages/Kubernetes/View/ServiceMesh";
import KubernetesClusterViewDelete from "../Pages/Kubernetes/View/Delete";
import KubernetesClusterViewSettings from "../Pages/Kubernetes/View/Settings";
import KubernetesClusterViewDocumentation from "../Pages/Kubernetes/View/Documentation";
@@ -357,6 +366,134 @@ const KubernetesRoutes: FunctionComponent<ComponentProps> = (
}
/>
{/* PVCs */}
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_PVCS,
)}
element={
<KubernetesClusterViewPVCs
{...props}
pageRoute={
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_PVCS] as Route
}
/>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_PVC_DETAIL,
2,
)}
element={
<KubernetesClusterViewPVCDetail
{...props}
pageRoute={
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_PVC_DETAIL
] as Route
}
/>
}
/>
{/* PVs */}
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_PVS,
)}
element={
<KubernetesClusterViewPVs
{...props}
pageRoute={
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_PVS] as Route
}
/>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_PV_DETAIL,
2,
)}
element={
<KubernetesClusterViewPVDetail
{...props}
pageRoute={
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_PV_DETAIL
] as Route
}
/>
}
/>
{/* HPAs */}
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_HPAS,
)}
element={
<KubernetesClusterViewHPAs
{...props}
pageRoute={
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_HPAS] as Route
}
/>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_HPA_DETAIL,
2,
)}
element={
<KubernetesClusterViewHPADetail
{...props}
pageRoute={
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_HPA_DETAIL
] as Route
}
/>
}
/>
{/* VPAs */}
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_VPAS,
)}
element={
<KubernetesClusterViewVPAs
{...props}
pageRoute={
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_VPAS] as Route
}
/>
}
/>
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_VPA_DETAIL,
2,
)}
element={
<KubernetesClusterViewVPADetail
{...props}
pageRoute={
RouteMap[
PageMap.KUBERNETES_CLUSTER_VIEW_VPA_DETAIL
] as Route
}
/>
}
/>
{/* Events */}
<PageRoute
path={RouteUtil.getLastPathForKey(
@@ -387,6 +524,21 @@ const KubernetesRoutes: FunctionComponent<ComponentProps> = (
}
/>
{/* Service Mesh */}
<PageRoute
path={RouteUtil.getLastPathForKey(
PageMap.KUBERNETES_CLUSTER_VIEW_SERVICE_MESH,
)}
element={
<KubernetesClusterViewServiceMesh
{...props}
pageRoute={
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_SERVICE_MESH] as Route
}
/>
}
/>
{/* Settings */}
<PageRoute
path={RouteUtil.getLastPathForKey(

View File

@@ -146,6 +146,28 @@ export function getKubernetesBreadcrumbs(
],
),
// Scaling
...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_HPAS, [
"Project",
"Kubernetes",
"View Cluster",
"HPAs",
]),
...BuildBreadcrumbLinksByTitles(
PageMap.KUBERNETES_CLUSTER_VIEW_HPA_DETAIL,
["Project", "Kubernetes", "View Cluster", "HPAs", "HPA Detail"],
),
...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_VPAS, [
"Project",
"Kubernetes",
"View Cluster",
"VPAs",
]),
...BuildBreadcrumbLinksByTitles(
PageMap.KUBERNETES_CLUSTER_VIEW_VPA_DETAIL,
["Project", "Kubernetes", "View Cluster", "VPAs", "VPA Detail"],
),
// Observability
...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS, [
"Project",
@@ -157,6 +179,10 @@ export function getKubernetesBreadcrumbs(
PageMap.KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE,
["Project", "Kubernetes", "View Cluster", "Control Plane"],
),
...BuildBreadcrumbLinksByTitles(
PageMap.KUBERNETES_CLUSTER_VIEW_SERVICE_MESH,
["Project", "Kubernetes", "View Cluster", "Service Mesh"],
),
// Advanced
...BuildBreadcrumbLinksByTitles(PageMap.KUBERNETES_CLUSTER_VIEW_DELETE, [

View File

@@ -260,7 +260,7 @@ export default class CriteriaFilterUtil {
});
}
if (monitorType === MonitorType.Metrics) {
if (monitorType === MonitorType.Metrics || monitorType === MonitorType.Kubernetes) {
options = options.filter((i: DropdownOption) => {
return i.value === CheckOn.MetricValue;
});

View File

@@ -238,8 +238,17 @@ enum PageMap {
KUBERNETES_CLUSTER_VIEW_NODE_DETAIL = "KUBERNETES_CLUSTER_VIEW_NODE_DETAIL",
KUBERNETES_CLUSTER_VIEW_CONTAINERS = "KUBERNETES_CLUSTER_VIEW_CONTAINERS",
KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL = "KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL",
KUBERNETES_CLUSTER_VIEW_PVCS = "KUBERNETES_CLUSTER_VIEW_PVCS",
KUBERNETES_CLUSTER_VIEW_PVC_DETAIL = "KUBERNETES_CLUSTER_VIEW_PVC_DETAIL",
KUBERNETES_CLUSTER_VIEW_PVS = "KUBERNETES_CLUSTER_VIEW_PVS",
KUBERNETES_CLUSTER_VIEW_PV_DETAIL = "KUBERNETES_CLUSTER_VIEW_PV_DETAIL",
KUBERNETES_CLUSTER_VIEW_HPAS = "KUBERNETES_CLUSTER_VIEW_HPAS",
KUBERNETES_CLUSTER_VIEW_HPA_DETAIL = "KUBERNETES_CLUSTER_VIEW_HPA_DETAIL",
KUBERNETES_CLUSTER_VIEW_VPAS = "KUBERNETES_CLUSTER_VIEW_VPAS",
KUBERNETES_CLUSTER_VIEW_VPA_DETAIL = "KUBERNETES_CLUSTER_VIEW_VPA_DETAIL",
KUBERNETES_CLUSTER_VIEW_EVENTS = "KUBERNETES_CLUSTER_VIEW_EVENTS",
KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE = "KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE",
KUBERNETES_CLUSTER_VIEW_SERVICE_MESH = "KUBERNETES_CLUSTER_VIEW_SERVICE_MESH",
KUBERNETES_CLUSTER_VIEW_DELETE = "KUBERNETES_CLUSTER_VIEW_DELETE",
KUBERNETES_CLUSTER_VIEW_SETTINGS = "KUBERNETES_CLUSTER_VIEW_SETTINGS",
KUBERNETES_CLUSTER_VIEW_DOCUMENTATION = "KUBERNETES_CLUSTER_VIEW_DOCUMENTATION",

View File

@@ -79,8 +79,17 @@ export const KubernetesRoutePath: Dictionary<string> = {
[PageMap.KUBERNETES_CLUSTER_VIEW_NODE_DETAIL]: `${RouteParams.ModelID}/nodes/${RouteParams.SubModelID}`,
[PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINERS]: `${RouteParams.ModelID}/containers`,
[PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINER_DETAIL]: `${RouteParams.ModelID}/containers/${RouteParams.SubModelID}`,
[PageMap.KUBERNETES_CLUSTER_VIEW_PVCS]: `${RouteParams.ModelID}/pvcs`,
[PageMap.KUBERNETES_CLUSTER_VIEW_PVC_DETAIL]: `${RouteParams.ModelID}/pvcs/${RouteParams.SubModelID}`,
[PageMap.KUBERNETES_CLUSTER_VIEW_PVS]: `${RouteParams.ModelID}/pvs`,
[PageMap.KUBERNETES_CLUSTER_VIEW_PV_DETAIL]: `${RouteParams.ModelID}/pvs/${RouteParams.SubModelID}`,
[PageMap.KUBERNETES_CLUSTER_VIEW_HPAS]: `${RouteParams.ModelID}/hpas`,
[PageMap.KUBERNETES_CLUSTER_VIEW_HPA_DETAIL]: `${RouteParams.ModelID}/hpas/${RouteParams.SubModelID}`,
[PageMap.KUBERNETES_CLUSTER_VIEW_VPAS]: `${RouteParams.ModelID}/vpas`,
[PageMap.KUBERNETES_CLUSTER_VIEW_VPA_DETAIL]: `${RouteParams.ModelID}/vpas/${RouteParams.SubModelID}`,
[PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS]: `${RouteParams.ModelID}/events`,
[PageMap.KUBERNETES_CLUSTER_VIEW_CONTROL_PLANE]: `${RouteParams.ModelID}/control-plane`,
[PageMap.KUBERNETES_CLUSTER_VIEW_SERVICE_MESH]: `${RouteParams.ModelID}/service-mesh`,
[PageMap.KUBERNETES_CLUSTER_VIEW_DELETE]: `${RouteParams.ModelID}/delete`,
[PageMap.KUBERNETES_CLUSTER_VIEW_SETTINGS]: `${RouteParams.ModelID}/settings`,
[PageMap.KUBERNETES_CLUSTER_VIEW_DOCUMENTATION]: `${RouteParams.ModelID}/documentation`,
@@ -1621,6 +1630,54 @@ const RouteMap: Dictionary<Route> = {
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_PVCS]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_PVCS]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_PVC_DETAIL]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_PVC_DETAIL]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_PVS]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_PVS]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_PV_DETAIL]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_PV_DETAIL]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_HPAS]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_HPAS]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_HPA_DETAIL]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_HPA_DETAIL]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_VPAS]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_VPAS]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_VPA_DETAIL]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_VPA_DETAIL]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_EVENTS]
@@ -1633,6 +1690,12 @@ const RouteMap: Dictionary<Route> = {
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_SERVICE_MESH]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_SERVICE_MESH]
}`,
),
[PageMap.KUBERNETES_CLUSTER_VIEW_DELETE]: new Route(
`/dashboard/${RouteParams.ProjectID}/kubernetes/${
KubernetesRoutePath[PageMap.KUBERNETES_CLUSTER_VIEW_DELETE]

View File

@@ -79,9 +79,6 @@ export default class Markdown {
markdown: string,
contentType: MarkdownContentType,
): Promise<string> {
// Basic sanitization: neutralize script tags but preserve markdown syntax like '>' for blockquotes.
markdown = markdown.replace(/<script/gi, "&lt;script");
let renderer: Renderer | null = null;
if (contentType === MarkdownContentType.Blog) {
@@ -96,6 +93,15 @@ export default class Markdown {
renderer = this.getEmailRenderer();
}
// Escape raw HTML tokens in the markdown source to prevent XSS.
// This only affects raw HTML that users type directly (e.g. <img onerror=...>),
// not HTML generated by the renderer methods (headings, paragraphs, etc.).
if (renderer) {
renderer.html = (html: string): string => {
return Markdown.escapeHtml(html);
};
}
const htmlBody: string = await marked(markdown, {
renderer: renderer,
});

View File

@@ -447,7 +447,10 @@ ${contextBlock}
}
}
if (input.monitor.monitorType === MonitorType.Metrics) {
if (
input.monitor.monitorType === MonitorType.Metrics ||
input.monitor.monitorType === MonitorType.Kubernetes
) {
const metricMonitorResult: string | null =
await MetricMonitorCriteria.isMonitorInstanceCriteriaFilterMet({
dataToProcess: input.dataToProcess,

View File

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

View File

@@ -16,4 +16,5 @@ export default interface MetricQueryConfigData {
metricQueryData: MetricQueryData;
getSeries?: ((data: AggregatedModel) => ChartSeries) | undefined;
chartType?: MetricChartType | undefined;
yAxisValueFormatter?: ((value: number) => string) | undefined;
}

View File

@@ -226,6 +226,25 @@ export class CriteriaFilterUtil {
];
}
public static getInverseFilterType(filterType: FilterType): FilterType {
switch (filterType) {
case FilterType.GreaterThan:
return FilterType.LessThanOrEqualTo;
case FilterType.LessThan:
return FilterType.GreaterThanOrEqualTo;
case FilterType.GreaterThanOrEqualTo:
return FilterType.LessThan;
case FilterType.LessThanOrEqualTo:
return FilterType.GreaterThan;
case FilterType.EqualTo:
return FilterType.NotEqualTo;
case FilterType.NotEqualTo:
return FilterType.EqualTo;
default:
return filterType;
}
}
public static isEvaluateOverTimeFilter(checkOn: CheckOn): boolean {
return (
checkOn === CheckOn.ResponseStatusCode ||

View File

@@ -0,0 +1,706 @@
import ObjectID from "../ObjectID";
import MonitorStep from "./MonitorStep";
import MonitorCriteria from "./MonitorCriteria";
import MonitorCriteriaInstance from "./MonitorCriteriaInstance";
import FilterCondition from "../Filter/FilterCondition";
import {
CheckOn,
FilterType,
EvaluateOverTimeType,
} from "./CriteriaFilter";
import MonitorStepKubernetesMonitor, {
KubernetesResourceScope,
} from "./MonitorStepKubernetesMonitor";
import RollingTime from "../RollingTime/RollingTime";
import MetricsAggregationType from "../Metrics/MetricsAggregationType";
export type KubernetesAlertTemplateCategory =
| "Workload"
| "Node"
| "ControlPlane"
| "Storage"
| "Scheduling";
export type KubernetesAlertTemplateSeverity = "Critical" | "Warning";
export interface KubernetesAlertTemplateArgs {
clusterIdentifier: string;
onlineMonitorStatusId: ObjectID;
offlineMonitorStatusId: ObjectID;
defaultIncidentSeverityId: ObjectID;
defaultAlertSeverityId: ObjectID;
monitorName: string;
}
export interface KubernetesAlertTemplate {
id: string;
name: string;
description: string;
category: KubernetesAlertTemplateCategory;
severity: KubernetesAlertTemplateSeverity;
getMonitorStep: (args: KubernetesAlertTemplateArgs) => MonitorStep;
}
export function buildKubernetesMonitorStep(args: {
kubernetesMonitor: MonitorStepKubernetesMonitor;
offlineCriteriaInstance: MonitorCriteriaInstance;
onlineCriteriaInstance: MonitorCriteriaInstance;
}): MonitorStep {
const monitorStep: MonitorStep = new MonitorStep();
const monitorCriteria: MonitorCriteria = new MonitorCriteria();
monitorCriteria.data = {
monitorCriteriaInstanceArray: [
args.offlineCriteriaInstance,
args.onlineCriteriaInstance,
],
};
monitorStep.data = {
id: ObjectID.generate().toString(),
monitorDestination: undefined,
doNotFollowRedirects: undefined,
monitorDestinationPort: undefined,
monitorCriteria: monitorCriteria,
requestType: "GET" as any,
requestHeaders: undefined,
requestBody: undefined,
customCode: undefined,
screenSizeTypes: undefined,
browserTypes: undefined,
retryCountOnError: undefined,
logMonitor: undefined,
traceMonitor: undefined,
metricMonitor: undefined,
exceptionMonitor: undefined,
snmpMonitor: undefined,
dnsMonitor: undefined,
domainMonitor: undefined,
externalStatusPageMonitor: undefined,
kubernetesMonitor: args.kubernetesMonitor,
};
return monitorStep;
}
export function buildOfflineCriteriaInstance(args: {
offlineMonitorStatusId: ObjectID;
incidentSeverityId: ObjectID;
alertSeverityId: ObjectID;
monitorName: string;
metricAlias: string;
filterType: FilterType;
value: number;
}): MonitorCriteriaInstance {
const instance: MonitorCriteriaInstance = new MonitorCriteriaInstance();
instance.data = {
id: ObjectID.generate().toString(),
monitorStatusId: args.offlineMonitorStatusId,
filterCondition: FilterCondition.Any,
filters: [
{
checkOn: CheckOn.MetricValue,
filterType: args.filterType,
metricMonitorOptions: {
metricAggregationType: EvaluateOverTimeType.AnyValue,
metricAlias: args.metricAlias,
},
value: args.value,
},
],
incidents: [
{
title: `${args.monitorName} - Alert Triggered`,
description: `${args.monitorName} has triggered an alert condition.`,
incidentSeverityId: args.incidentSeverityId,
autoResolveIncident: true,
id: ObjectID.generate().toString(),
onCallPolicyIds: [],
},
],
alerts: [
{
title: `${args.monitorName} - Alert`,
description: `${args.monitorName} has triggered an alert condition.`,
alertSeverityId: args.alertSeverityId,
autoResolveAlert: true,
id: ObjectID.generate().toString(),
onCallPolicyIds: [],
},
],
changeMonitorStatus: true,
createIncidents: true,
createAlerts: true,
name: `${args.monitorName} - Unhealthy`,
description: `Criteria for detecting unhealthy state.`,
};
return instance;
}
export function buildOnlineCriteriaInstance(args: {
onlineMonitorStatusId: ObjectID;
metricAlias: string;
filterType: FilterType;
value: number;
}): MonitorCriteriaInstance {
const instance: MonitorCriteriaInstance = new MonitorCriteriaInstance();
instance.data = {
id: ObjectID.generate().toString(),
monitorStatusId: args.onlineMonitorStatusId,
filterCondition: FilterCondition.Any,
filters: [
{
checkOn: CheckOn.MetricValue,
filterType: args.filterType,
metricMonitorOptions: {
metricAggregationType: EvaluateOverTimeType.AnyValue,
metricAlias: args.metricAlias,
},
value: args.value,
},
],
incidents: [],
alerts: [],
changeMonitorStatus: true,
createIncidents: false,
createAlerts: false,
name: "Healthy",
description: "Criteria for healthy state.",
};
return instance;
}
export function buildKubernetesMonitorConfig(args: {
clusterIdentifier: string;
metricName: string;
metricAlias: string;
resourceScope: KubernetesResourceScope;
rollingTime: RollingTime;
aggregationType: MetricsAggregationType;
attributes?: Record<string, string>;
}): MonitorStepKubernetesMonitor {
return {
clusterIdentifier: args.clusterIdentifier,
resourceScope: args.resourceScope,
resourceFilters: {},
metricViewConfig: {
queryConfigs: [
{
metricAliasData: {
metricVariable: args.metricAlias,
title: args.metricAlias,
description: args.metricAlias,
legend: args.metricAlias,
legendUnit: undefined,
},
metricQueryData: {
filterData: {
metricName: args.metricName,
attributes: args.attributes || {},
aggegationType: args.aggregationType,
aggregateBy: {},
},
},
},
],
formulaConfigs: [],
},
rollingTime: args.rollingTime,
};
}
// --- Template Definitions ---
const crashLoopBackOffTemplate: KubernetesAlertTemplate = {
id: "k8s-crashloopbackoff",
name: "CrashLoopBackOff Detection",
description:
"Alert when container restart count exceeds threshold, indicating a CrashLoopBackOff condition.",
category: "Workload",
severity: "Critical",
getMonitorStep: (args: KubernetesAlertTemplateArgs): MonitorStep => {
const metricAlias: string = "container_restarts";
return buildKubernetesMonitorStep({
kubernetesMonitor: buildKubernetesMonitorConfig({
clusterIdentifier: args.clusterIdentifier,
metricName: "k8s.container.restarts",
metricAlias,
resourceScope: KubernetesResourceScope.Cluster,
rollingTime: RollingTime.Past5Minutes,
aggregationType: MetricsAggregationType.Max,
}),
offlineCriteriaInstance: buildOfflineCriteriaInstance({
offlineMonitorStatusId: args.offlineMonitorStatusId,
incidentSeverityId: args.defaultIncidentSeverityId,
alertSeverityId: args.defaultAlertSeverityId,
monitorName: args.monitorName,
metricAlias,
filterType: FilterType.GreaterThan,
value: 5,
}),
onlineCriteriaInstance: buildOnlineCriteriaInstance({
onlineMonitorStatusId: args.onlineMonitorStatusId,
metricAlias,
filterType: FilterType.LessThanOrEqualTo,
value: 5,
}),
});
},
};
const podPendingTemplate: KubernetesAlertTemplate = {
id: "k8s-pod-pending",
name: "Pod Stuck in Pending",
description:
"Alert when pods remain in Pending phase, indicating scheduling or resource issues.",
category: "Scheduling",
severity: "Warning",
getMonitorStep: (args: KubernetesAlertTemplateArgs): MonitorStep => {
const metricAlias: string = "pending_pods";
return buildKubernetesMonitorStep({
kubernetesMonitor: buildKubernetesMonitorConfig({
clusterIdentifier: args.clusterIdentifier,
metricName: "k8s.pod.phase",
metricAlias,
resourceScope: KubernetesResourceScope.Cluster,
rollingTime: RollingTime.Past5Minutes,
aggregationType: MetricsAggregationType.Sum,
attributes: { "k8s.pod.phase": "Pending" },
}),
offlineCriteriaInstance: buildOfflineCriteriaInstance({
offlineMonitorStatusId: args.offlineMonitorStatusId,
incidentSeverityId: args.defaultIncidentSeverityId,
alertSeverityId: args.defaultAlertSeverityId,
monitorName: args.monitorName,
metricAlias,
filterType: FilterType.GreaterThan,
value: 0,
}),
onlineCriteriaInstance: buildOnlineCriteriaInstance({
onlineMonitorStatusId: args.onlineMonitorStatusId,
metricAlias,
filterType: FilterType.EqualTo,
value: 0,
}),
});
},
};
const nodeNotReadyTemplate: KubernetesAlertTemplate = {
id: "k8s-node-not-ready",
name: "Node Not Ready",
description:
"Alert when a node condition transitions to NotReady, indicating node health issues.",
category: "Node",
severity: "Critical",
getMonitorStep: (args: KubernetesAlertTemplateArgs): MonitorStep => {
const metricAlias: string = "node_ready";
return buildKubernetesMonitorStep({
kubernetesMonitor: buildKubernetesMonitorConfig({
clusterIdentifier: args.clusterIdentifier,
metricName: "k8s.node.condition_ready",
metricAlias,
resourceScope: KubernetesResourceScope.Node,
rollingTime: RollingTime.Past5Minutes,
aggregationType: MetricsAggregationType.Min,
}),
offlineCriteriaInstance: buildOfflineCriteriaInstance({
offlineMonitorStatusId: args.offlineMonitorStatusId,
incidentSeverityId: args.defaultIncidentSeverityId,
alertSeverityId: args.defaultAlertSeverityId,
monitorName: args.monitorName,
metricAlias,
filterType: FilterType.EqualTo,
value: 0,
}),
onlineCriteriaInstance: buildOnlineCriteriaInstance({
onlineMonitorStatusId: args.onlineMonitorStatusId,
metricAlias,
filterType: FilterType.GreaterThan,
value: 0,
}),
});
},
};
const highCpuTemplate: KubernetesAlertTemplate = {
id: "k8s-high-cpu",
name: "High Node CPU Utilization",
description:
"Alert when node CPU utilization exceeds 90% sustained.",
category: "Node",
severity: "Warning",
getMonitorStep: (args: KubernetesAlertTemplateArgs): MonitorStep => {
const metricAlias: string = "node_cpu";
return buildKubernetesMonitorStep({
kubernetesMonitor: buildKubernetesMonitorConfig({
clusterIdentifier: args.clusterIdentifier,
metricName: "k8s.node.cpu.utilization",
metricAlias,
resourceScope: KubernetesResourceScope.Node,
rollingTime: RollingTime.Past5Minutes,
aggregationType: MetricsAggregationType.Avg,
}),
offlineCriteriaInstance: buildOfflineCriteriaInstance({
offlineMonitorStatusId: args.offlineMonitorStatusId,
incidentSeverityId: args.defaultIncidentSeverityId,
alertSeverityId: args.defaultAlertSeverityId,
monitorName: args.monitorName,
metricAlias,
filterType: FilterType.GreaterThan,
value: 90,
}),
onlineCriteriaInstance: buildOnlineCriteriaInstance({
onlineMonitorStatusId: args.onlineMonitorStatusId,
metricAlias,
filterType: FilterType.LessThanOrEqualTo,
value: 90,
}),
});
},
};
const highMemoryTemplate: KubernetesAlertTemplate = {
id: "k8s-high-memory",
name: "High Node Memory Utilization",
description:
"Alert when node memory utilization exceeds 85% sustained.",
category: "Node",
severity: "Warning",
getMonitorStep: (args: KubernetesAlertTemplateArgs): MonitorStep => {
const metricAlias: string = "node_memory";
return buildKubernetesMonitorStep({
kubernetesMonitor: buildKubernetesMonitorConfig({
clusterIdentifier: args.clusterIdentifier,
metricName: "k8s.node.memory.usage",
metricAlias,
resourceScope: KubernetesResourceScope.Node,
rollingTime: RollingTime.Past5Minutes,
aggregationType: MetricsAggregationType.Avg,
}),
offlineCriteriaInstance: buildOfflineCriteriaInstance({
offlineMonitorStatusId: args.offlineMonitorStatusId,
incidentSeverityId: args.defaultIncidentSeverityId,
alertSeverityId: args.defaultAlertSeverityId,
monitorName: args.monitorName,
metricAlias,
filterType: FilterType.GreaterThan,
value: 85,
}),
onlineCriteriaInstance: buildOnlineCriteriaInstance({
onlineMonitorStatusId: args.onlineMonitorStatusId,
metricAlias,
filterType: FilterType.LessThanOrEqualTo,
value: 85,
}),
});
},
};
const deploymentReplicaMismatchTemplate: KubernetesAlertTemplate = {
id: "k8s-deployment-replica-mismatch",
name: "Deployment Replica Mismatch",
description:
"Alert when available replicas are less than desired replicas for a deployment.",
category: "Workload",
severity: "Warning",
getMonitorStep: (args: KubernetesAlertTemplateArgs): MonitorStep => {
const metricAlias: string = "unavailable_replicas";
return buildKubernetesMonitorStep({
kubernetesMonitor: buildKubernetesMonitorConfig({
clusterIdentifier: args.clusterIdentifier,
metricName: "k8s.deployment.unavailable_replicas",
metricAlias,
resourceScope: KubernetesResourceScope.Workload,
rollingTime: RollingTime.Past5Minutes,
aggregationType: MetricsAggregationType.Max,
}),
offlineCriteriaInstance: buildOfflineCriteriaInstance({
offlineMonitorStatusId: args.offlineMonitorStatusId,
incidentSeverityId: args.defaultIncidentSeverityId,
alertSeverityId: args.defaultAlertSeverityId,
monitorName: args.monitorName,
metricAlias,
filterType: FilterType.GreaterThan,
value: 0,
}),
onlineCriteriaInstance: buildOnlineCriteriaInstance({
onlineMonitorStatusId: args.onlineMonitorStatusId,
metricAlias,
filterType: FilterType.EqualTo,
value: 0,
}),
});
},
};
const jobFailuresTemplate: KubernetesAlertTemplate = {
id: "k8s-job-failures",
name: "Job Failures",
description: "Alert when Kubernetes jobs fail.",
category: "Workload",
severity: "Warning",
getMonitorStep: (args: KubernetesAlertTemplateArgs): MonitorStep => {
const metricAlias: string = "failed_pods";
return buildKubernetesMonitorStep({
kubernetesMonitor: buildKubernetesMonitorConfig({
clusterIdentifier: args.clusterIdentifier,
metricName: "k8s.job.failed_pods",
metricAlias,
resourceScope: KubernetesResourceScope.Workload,
rollingTime: RollingTime.Past5Minutes,
aggregationType: MetricsAggregationType.Max,
}),
offlineCriteriaInstance: buildOfflineCriteriaInstance({
offlineMonitorStatusId: args.offlineMonitorStatusId,
incidentSeverityId: args.defaultIncidentSeverityId,
alertSeverityId: args.defaultAlertSeverityId,
monitorName: args.monitorName,
metricAlias,
filterType: FilterType.GreaterThan,
value: 0,
}),
onlineCriteriaInstance: buildOnlineCriteriaInstance({
onlineMonitorStatusId: args.onlineMonitorStatusId,
metricAlias,
filterType: FilterType.EqualTo,
value: 0,
}),
});
},
};
const etcdNoLeaderTemplate: KubernetesAlertTemplate = {
id: "k8s-etcd-no-leader",
name: "etcd No Leader",
description:
"Alert immediately when etcd has no leader elected. This is a critical cluster health issue.",
category: "ControlPlane",
severity: "Critical",
getMonitorStep: (args: KubernetesAlertTemplateArgs): MonitorStep => {
const metricAlias: string = "etcd_has_leader";
return buildKubernetesMonitorStep({
kubernetesMonitor: buildKubernetesMonitorConfig({
clusterIdentifier: args.clusterIdentifier,
metricName: "etcd_server_has_leader",
metricAlias,
resourceScope: KubernetesResourceScope.Cluster,
rollingTime: RollingTime.Past1Minute,
aggregationType: MetricsAggregationType.Min,
}),
offlineCriteriaInstance: buildOfflineCriteriaInstance({
offlineMonitorStatusId: args.offlineMonitorStatusId,
incidentSeverityId: args.defaultIncidentSeverityId,
alertSeverityId: args.defaultAlertSeverityId,
monitorName: args.monitorName,
metricAlias,
filterType: FilterType.EqualTo,
value: 0,
}),
onlineCriteriaInstance: buildOnlineCriteriaInstance({
onlineMonitorStatusId: args.onlineMonitorStatusId,
metricAlias,
filterType: FilterType.GreaterThan,
value: 0,
}),
});
},
};
const apiServerThrottlingTemplate: KubernetesAlertTemplate = {
id: "k8s-apiserver-throttling",
name: "API Server Throttling",
description:
"Alert when the Kubernetes API server is dropping requests due to throttling.",
category: "ControlPlane",
severity: "Critical",
getMonitorStep: (args: KubernetesAlertTemplateArgs): MonitorStep => {
const metricAlias: string = "dropped_requests";
return buildKubernetesMonitorStep({
kubernetesMonitor: buildKubernetesMonitorConfig({
clusterIdentifier: args.clusterIdentifier,
metricName: "apiserver_dropped_requests_total",
metricAlias,
resourceScope: KubernetesResourceScope.Cluster,
rollingTime: RollingTime.Past5Minutes,
aggregationType: MetricsAggregationType.Sum,
}),
offlineCriteriaInstance: buildOfflineCriteriaInstance({
offlineMonitorStatusId: args.offlineMonitorStatusId,
incidentSeverityId: args.defaultIncidentSeverityId,
alertSeverityId: args.defaultAlertSeverityId,
monitorName: args.monitorName,
metricAlias,
filterType: FilterType.GreaterThan,
value: 0,
}),
onlineCriteriaInstance: buildOnlineCriteriaInstance({
onlineMonitorStatusId: args.onlineMonitorStatusId,
metricAlias,
filterType: FilterType.EqualTo,
value: 0,
}),
});
},
};
const schedulerBacklogTemplate: KubernetesAlertTemplate = {
id: "k8s-scheduler-backlog",
name: "Scheduler Backlog",
description:
"Alert when there are pods waiting to be scheduled for more than 5 minutes.",
category: "Scheduling",
severity: "Warning",
getMonitorStep: (args: KubernetesAlertTemplateArgs): MonitorStep => {
const metricAlias: string = "pending_pods";
return buildKubernetesMonitorStep({
kubernetesMonitor: buildKubernetesMonitorConfig({
clusterIdentifier: args.clusterIdentifier,
metricName: "scheduler_pending_pods",
metricAlias,
resourceScope: KubernetesResourceScope.Cluster,
rollingTime: RollingTime.Past5Minutes,
aggregationType: MetricsAggregationType.Avg,
}),
offlineCriteriaInstance: buildOfflineCriteriaInstance({
offlineMonitorStatusId: args.offlineMonitorStatusId,
incidentSeverityId: args.defaultIncidentSeverityId,
alertSeverityId: args.defaultAlertSeverityId,
monitorName: args.monitorName,
metricAlias,
filterType: FilterType.GreaterThan,
value: 0,
}),
onlineCriteriaInstance: buildOnlineCriteriaInstance({
onlineMonitorStatusId: args.onlineMonitorStatusId,
metricAlias,
filterType: FilterType.EqualTo,
value: 0,
}),
});
},
};
const highDiskUsageTemplate: KubernetesAlertTemplate = {
id: "k8s-high-disk-usage",
name: "High Node Disk Usage",
description:
"Alert when node filesystem usage exceeds 90% capacity.",
category: "Storage",
severity: "Warning",
getMonitorStep: (args: KubernetesAlertTemplateArgs): MonitorStep => {
const metricAlias: string = "disk_usage";
return buildKubernetesMonitorStep({
kubernetesMonitor: buildKubernetesMonitorConfig({
clusterIdentifier: args.clusterIdentifier,
metricName: "k8s.node.filesystem.usage",
metricAlias,
resourceScope: KubernetesResourceScope.Node,
rollingTime: RollingTime.Past5Minutes,
aggregationType: MetricsAggregationType.Avg,
}),
offlineCriteriaInstance: buildOfflineCriteriaInstance({
offlineMonitorStatusId: args.offlineMonitorStatusId,
incidentSeverityId: args.defaultIncidentSeverityId,
alertSeverityId: args.defaultAlertSeverityId,
monitorName: args.monitorName,
metricAlias,
filterType: FilterType.GreaterThan,
value: 90,
}),
onlineCriteriaInstance: buildOnlineCriteriaInstance({
onlineMonitorStatusId: args.onlineMonitorStatusId,
metricAlias,
filterType: FilterType.LessThanOrEqualTo,
value: 90,
}),
});
},
};
const daemonSetUnavailableTemplate: KubernetesAlertTemplate = {
id: "k8s-daemonset-unavailable",
name: "DaemonSet Unavailable Nodes",
description:
"Alert when a DaemonSet has unavailable nodes where the daemon pod should be running.",
category: "Workload",
severity: "Warning",
getMonitorStep: (args: KubernetesAlertTemplateArgs): MonitorStep => {
const metricAlias: string = "unavailable_nodes";
return buildKubernetesMonitorStep({
kubernetesMonitor: buildKubernetesMonitorConfig({
clusterIdentifier: args.clusterIdentifier,
metricName: "k8s.daemonset.misscheduled_nodes",
metricAlias,
resourceScope: KubernetesResourceScope.Workload,
rollingTime: RollingTime.Past5Minutes,
aggregationType: MetricsAggregationType.Max,
}),
offlineCriteriaInstance: buildOfflineCriteriaInstance({
offlineMonitorStatusId: args.offlineMonitorStatusId,
incidentSeverityId: args.defaultIncidentSeverityId,
alertSeverityId: args.defaultAlertSeverityId,
monitorName: args.monitorName,
metricAlias,
filterType: FilterType.GreaterThan,
value: 0,
}),
onlineCriteriaInstance: buildOnlineCriteriaInstance({
onlineMonitorStatusId: args.onlineMonitorStatusId,
metricAlias,
filterType: FilterType.EqualTo,
value: 0,
}),
});
},
};
export function getAllKubernetesAlertTemplates(): Array<KubernetesAlertTemplate> {
return [
crashLoopBackOffTemplate,
podPendingTemplate,
nodeNotReadyTemplate,
highCpuTemplate,
highMemoryTemplate,
deploymentReplicaMismatchTemplate,
jobFailuresTemplate,
etcdNoLeaderTemplate,
apiServerThrottlingTemplate,
schedulerBacklogTemplate,
highDiskUsageTemplate,
daemonSetUnavailableTemplate,
];
}
export function getKubernetesAlertTemplatesByCategory(
category: KubernetesAlertTemplateCategory,
): Array<KubernetesAlertTemplate> {
return getAllKubernetesAlertTemplates().filter(
(template: KubernetesAlertTemplate) => template.category === category,
);
}
export function getKubernetesAlertTemplateById(
id: string,
): KubernetesAlertTemplate | undefined {
return getAllKubernetesAlertTemplates().find(
(template: KubernetesAlertTemplate) => template.id === id,
);
}

View File

@@ -0,0 +1,344 @@
import { KubernetesResourceScope } from "./MonitorStepKubernetesMonitor";
import MetricsAggregationType from "../Metrics/MetricsAggregationType";
export type KubernetesMetricCategory =
| "Pod"
| "Node"
| "Container"
| "Workload"
| "HPA";
export interface KubernetesMetricDefinition {
id: string;
friendlyName: string;
description: string;
metricName: string;
category: KubernetesMetricCategory;
defaultAggregation: MetricsAggregationType;
defaultResourceScope: KubernetesResourceScope;
unit?: string;
}
const kubernetesMetricCatalog: Array<KubernetesMetricDefinition> = [
// Pod Metrics
{
id: "pod-cpu-utilization",
friendlyName: "Pod CPU Utilization",
description: "CPU usage percentage for pods",
metricName: "k8s.pod.cpu.utilization",
category: "Pod",
defaultAggregation: MetricsAggregationType.Avg,
defaultResourceScope: KubernetesResourceScope.Pod,
unit: "%",
},
{
id: "pod-memory-usage",
friendlyName: "Pod Memory Usage",
description: "Memory usage in bytes for pods",
metricName: "k8s.pod.memory.usage",
category: "Pod",
defaultAggregation: MetricsAggregationType.Avg,
defaultResourceScope: KubernetesResourceScope.Pod,
unit: "bytes",
},
{
id: "pod-phase",
friendlyName: "Pod Phase",
description: "Current phase of the pod (Pending, Running, Succeeded, Failed, Unknown)",
metricName: "k8s.pod.phase",
category: "Pod",
defaultAggregation: MetricsAggregationType.Sum,
defaultResourceScope: KubernetesResourceScope.Cluster,
unit: "count",
},
{
id: "pod-filesystem-usage",
friendlyName: "Pod Filesystem Usage",
description: "Filesystem usage in bytes for pods",
metricName: "k8s.pod.filesystem.usage",
category: "Pod",
defaultAggregation: MetricsAggregationType.Avg,
defaultResourceScope: KubernetesResourceScope.Pod,
unit: "bytes",
},
{
id: "pod-network-io-receive",
friendlyName: "Pod Network Receive",
description: "Network bytes received by pods",
metricName: "k8s.pod.network.io",
category: "Pod",
defaultAggregation: MetricsAggregationType.Sum,
defaultResourceScope: KubernetesResourceScope.Pod,
unit: "bytes",
},
// Node Metrics
{
id: "node-cpu-utilization",
friendlyName: "Node CPU Utilization",
description: "CPU usage percentage for nodes",
metricName: "k8s.node.cpu.utilization",
category: "Node",
defaultAggregation: MetricsAggregationType.Avg,
defaultResourceScope: KubernetesResourceScope.Node,
unit: "%",
},
{
id: "node-memory-usage",
friendlyName: "Node Memory Usage",
description: "Memory usage in bytes for nodes",
metricName: "k8s.node.memory.usage",
category: "Node",
defaultAggregation: MetricsAggregationType.Avg,
defaultResourceScope: KubernetesResourceScope.Node,
unit: "bytes",
},
{
id: "node-filesystem-usage",
friendlyName: "Node Filesystem Usage",
description: "Filesystem usage in bytes for nodes",
metricName: "k8s.node.filesystem.usage",
category: "Node",
defaultAggregation: MetricsAggregationType.Avg,
defaultResourceScope: KubernetesResourceScope.Node,
unit: "bytes",
},
{
id: "node-condition-ready",
friendlyName: "Node Ready Condition",
description: "Whether the node is in Ready condition (1 = ready, 0 = not ready)",
metricName: "k8s.node.condition_ready",
category: "Node",
defaultAggregation: MetricsAggregationType.Min,
defaultResourceScope: KubernetesResourceScope.Node,
unit: "count",
},
{
id: "node-disk-io",
friendlyName: "Node Disk I/O",
description: "Disk I/O operations on nodes",
metricName: "k8s.node.filesystem.available",
category: "Node",
defaultAggregation: MetricsAggregationType.Avg,
defaultResourceScope: KubernetesResourceScope.Node,
unit: "bytes",
},
// Container Metrics
{
id: "container-restarts",
friendlyName: "Container Restarts",
description: "Number of times a container has restarted",
metricName: "k8s.container.restarts",
category: "Container",
defaultAggregation: MetricsAggregationType.Max,
defaultResourceScope: KubernetesResourceScope.Cluster,
unit: "count",
},
{
id: "container-cpu-limit",
friendlyName: "Container CPU Limit",
description: "CPU limit set for containers",
metricName: "k8s.container.cpu_limit",
category: "Container",
defaultAggregation: MetricsAggregationType.Avg,
defaultResourceScope: KubernetesResourceScope.Pod,
unit: "cores",
},
{
id: "container-cpu-request",
friendlyName: "Container CPU Request",
description: "CPU request set for containers",
metricName: "k8s.container.cpu_request",
category: "Container",
defaultAggregation: MetricsAggregationType.Avg,
defaultResourceScope: KubernetesResourceScope.Pod,
unit: "cores",
},
{
id: "container-memory-limit",
friendlyName: "Container Memory Limit",
description: "Memory limit set for containers",
metricName: "k8s.container.memory_limit",
category: "Container",
defaultAggregation: MetricsAggregationType.Avg,
defaultResourceScope: KubernetesResourceScope.Pod,
unit: "bytes",
},
{
id: "container-memory-request",
friendlyName: "Container Memory Request",
description: "Memory request set for containers",
metricName: "k8s.container.memory_request",
category: "Container",
defaultAggregation: MetricsAggregationType.Avg,
defaultResourceScope: KubernetesResourceScope.Pod,
unit: "bytes",
},
{
id: "container-ready",
friendlyName: "Container Ready",
description: "Whether the container is in Ready state",
metricName: "k8s.container.ready",
category: "Container",
defaultAggregation: MetricsAggregationType.Min,
defaultResourceScope: KubernetesResourceScope.Pod,
unit: "count",
},
// Workload Metrics
{
id: "deployment-available-replicas",
friendlyName: "Deployment Available Replicas",
description: "Number of available replicas in a deployment",
metricName: "k8s.deployment.available_replicas",
category: "Workload",
defaultAggregation: MetricsAggregationType.Min,
defaultResourceScope: KubernetesResourceScope.Workload,
unit: "count",
},
{
id: "deployment-desired-replicas",
friendlyName: "Deployment Desired Replicas",
description: "Number of desired replicas in a deployment",
metricName: "k8s.deployment.desired_replicas",
category: "Workload",
defaultAggregation: MetricsAggregationType.Max,
defaultResourceScope: KubernetesResourceScope.Workload,
unit: "count",
},
{
id: "deployment-unavailable-replicas",
friendlyName: "Deployment Unavailable Replicas",
description: "Number of unavailable replicas in a deployment",
metricName: "k8s.deployment.unavailable_replicas",
category: "Workload",
defaultAggregation: MetricsAggregationType.Max,
defaultResourceScope: KubernetesResourceScope.Workload,
unit: "count",
},
{
id: "daemonset-misscheduled-nodes",
friendlyName: "DaemonSet Misscheduled Nodes",
description: "Number of nodes running a daemon pod that should not be running one",
metricName: "k8s.daemonset.misscheduled_nodes",
category: "Workload",
defaultAggregation: MetricsAggregationType.Max,
defaultResourceScope: KubernetesResourceScope.Workload,
unit: "count",
},
{
id: "daemonset-ready-nodes",
friendlyName: "DaemonSet Ready Nodes",
description: "Number of nodes with a ready daemon pod",
metricName: "k8s.daemonset.ready_nodes",
category: "Workload",
defaultAggregation: MetricsAggregationType.Min,
defaultResourceScope: KubernetesResourceScope.Workload,
unit: "count",
},
{
id: "statefulset-ready-replicas",
friendlyName: "StatefulSet Ready Replicas",
description: "Number of ready replicas in a StatefulSet",
metricName: "k8s.statefulset.ready_replicas",
category: "Workload",
defaultAggregation: MetricsAggregationType.Min,
defaultResourceScope: KubernetesResourceScope.Workload,
unit: "count",
},
{
id: "job-failed-pods",
friendlyName: "Job Failed Pods",
description: "Number of failed pods in a Job",
metricName: "k8s.job.failed_pods",
category: "Workload",
defaultAggregation: MetricsAggregationType.Max,
defaultResourceScope: KubernetesResourceScope.Workload,
unit: "count",
},
{
id: "job-successful-pods",
friendlyName: "Job Successful Pods",
description: "Number of successful pods in a Job",
metricName: "k8s.job.successful_pods",
category: "Workload",
defaultAggregation: MetricsAggregationType.Max,
defaultResourceScope: KubernetesResourceScope.Workload,
unit: "count",
},
// HPA Metrics
{
id: "hpa-current-replicas",
friendlyName: "HPA Current Replicas",
description: "Current number of replicas managed by the HPA",
metricName: "k8s.hpa.current_replicas",
category: "HPA",
defaultAggregation: MetricsAggregationType.Avg,
defaultResourceScope: KubernetesResourceScope.Workload,
unit: "count",
},
{
id: "hpa-desired-replicas",
friendlyName: "HPA Desired Replicas",
description: "Desired number of replicas as determined by the HPA",
metricName: "k8s.hpa.desired_replicas",
category: "HPA",
defaultAggregation: MetricsAggregationType.Max,
defaultResourceScope: KubernetesResourceScope.Workload,
unit: "count",
},
{
id: "hpa-max-replicas",
friendlyName: "HPA Max Replicas",
description: "Maximum number of replicas the HPA can scale to",
metricName: "k8s.hpa.max_replicas",
category: "HPA",
defaultAggregation: MetricsAggregationType.Max,
defaultResourceScope: KubernetesResourceScope.Workload,
unit: "count",
},
{
id: "hpa-min-replicas",
friendlyName: "HPA Min Replicas",
description: "Minimum number of replicas the HPA maintains",
metricName: "k8s.hpa.min_replicas",
category: "HPA",
defaultAggregation: MetricsAggregationType.Min,
defaultResourceScope: KubernetesResourceScope.Workload,
unit: "count",
},
];
export function getAllKubernetesMetrics(): Array<KubernetesMetricDefinition> {
return kubernetesMetricCatalog;
}
export function getKubernetesMetricsByCategory(
category: KubernetesMetricCategory,
): Array<KubernetesMetricDefinition> {
return kubernetesMetricCatalog.filter(
(m: KubernetesMetricDefinition) => m.category === category,
);
}
export function getKubernetesMetricById(
id: string,
): KubernetesMetricDefinition | undefined {
return kubernetesMetricCatalog.find(
(m: KubernetesMetricDefinition) => m.id === id,
);
}
export function getKubernetesMetricByMetricName(
metricName: string,
): KubernetesMetricDefinition | undefined {
return kubernetesMetricCatalog.find(
(m: KubernetesMetricDefinition) => m.metricName === metricName,
);
}
export function getAllKubernetesMetricCategories(): Array<KubernetesMetricCategory> {
return ["Pod", "Node", "Container", "Workload", "HPA"];
}

View File

@@ -212,6 +212,43 @@ export default class MonitorCriteriaInstance extends DatabaseProperty {
return monitorCriteriaInstance;
}
if (arg.monitorType === MonitorType.Kubernetes) {
const monitorCriteriaInstance: MonitorCriteriaInstance =
new MonitorCriteriaInstance();
monitorCriteriaInstance.data = {
id: ObjectID.generate().toString(),
monitorStatusId: arg.monitorStatusId,
filterCondition: FilterCondition.Any,
filters: [
{
checkOn: CheckOn.MetricValue,
filterType: FilterType.GreaterThan,
metricMonitorOptions: {
metricAggregationType: EvaluateOverTimeType.AnyValue,
metricAlias:
arg.metricOptions &&
arg.metricOptions.metricAliases &&
arg.metricOptions.metricAliases.length > 0
? arg.metricOptions.metricAliases[0]
: undefined,
},
value: 0,
},
],
incidents: [],
alerts: [],
changeMonitorStatus: true,
createIncidents: false,
createAlerts: false,
name: `Check if ${arg.monitorName} is online`,
description: `This criteria checks if the ${arg.monitorName} is online`,
};
return monitorCriteriaInstance;
}
if (arg.monitorType === MonitorType.Traces) {
const monitorCriteriaInstance: MonitorCriteriaInstance =
new MonitorCriteriaInstance();
@@ -873,6 +910,55 @@ export default class MonitorCriteriaInstance extends DatabaseProperty {
};
}
if (arg.monitorType === MonitorType.Kubernetes) {
monitorCriteriaInstance.data = {
id: ObjectID.generate().toString(),
monitorStatusId: arg.monitorStatusId,
filterCondition: FilterCondition.Any,
filters: [
{
checkOn: CheckOn.MetricValue,
filterType: FilterType.EqualTo,
metricMonitorOptions: {
metricAggregationType: EvaluateOverTimeType.AnyValue,
metricAlias:
arg.metricOptions &&
arg.metricOptions.metricAliases &&
arg.metricOptions.metricAliases.length > 0
? arg.metricOptions.metricAliases[0]
: undefined,
},
value: 0,
},
],
incidents: [
{
title: `${arg.monitorName} is offline`,
description: `${arg.monitorName} is currently offline.`,
incidentSeverityId: arg.incidentSeverityId,
autoResolveIncident: true,
id: ObjectID.generate().toString(),
onCallPolicyIds: [],
},
],
alerts: [
{
title: `${arg.monitorName} is offline`,
description: `${arg.monitorName} is currently offline.`,
alertSeverityId: arg.alertSeverityId,
autoResolveAlert: true,
id: ObjectID.generate().toString(),
onCallPolicyIds: [],
},
],
createAlerts: false,
changeMonitorStatus: true,
createIncidents: true,
name: `Check if ${arg.monitorName} is offline`,
description: `This criteria checks if the ${arg.monitorName} is offline`,
};
}
if (arg.monitorType === MonitorType.Traces) {
monitorCriteriaInstance.data = {
id: ObjectID.generate().toString(),

View File

@@ -38,6 +38,9 @@ import MonitorStepDomainMonitor, {
import MonitorStepExternalStatusPageMonitor, {
MonitorStepExternalStatusPageMonitorUtil,
} from "./MonitorStepExternalStatusPageMonitor";
import MonitorStepKubernetesMonitor, {
MonitorStepKubernetesMonitorUtil,
} from "./MonitorStepKubernetesMonitor";
import Zod, { ZodSchema } from "../../Utils/Schema/Zod";
export interface MonitorStepType {
@@ -90,6 +93,9 @@ export interface MonitorStepType {
// External Status Page monitor
externalStatusPageMonitor?: MonitorStepExternalStatusPageMonitor | undefined;
// Kubernetes monitor
kubernetesMonitor?: MonitorStepKubernetesMonitor | undefined;
}
export default class MonitorStep extends DatabaseProperty {
@@ -119,6 +125,7 @@ export default class MonitorStep extends DatabaseProperty {
dnsMonitor: undefined,
domainMonitor: undefined,
externalStatusPageMonitor: undefined,
kubernetesMonitor: undefined,
};
}
@@ -153,6 +160,7 @@ export default class MonitorStep extends DatabaseProperty {
dnsMonitor: undefined,
domainMonitor: undefined,
externalStatusPageMonitor: undefined,
kubernetesMonitor: undefined,
};
return monitorStep;
@@ -267,6 +275,13 @@ export default class MonitorStep extends DatabaseProperty {
return this;
}
public setKubernetesMonitor(
kubernetesMonitor: MonitorStepKubernetesMonitor,
): MonitorStep {
this.data!.kubernetesMonitor = kubernetesMonitor;
return this;
}
public setCustomCode(customCode: string): MonitorStep {
this.data!.customCode = customCode;
return this;
@@ -293,8 +308,9 @@ export default class MonitorStep extends DatabaseProperty {
screenSizeTypes: undefined,
browserTypes: undefined,
retryCountOnError: undefined,
lgoMonitor: undefined,
logMonitor: undefined,
exceptionMonitor: undefined,
kubernetesMonitor: undefined,
},
};
}
@@ -405,6 +421,16 @@ export default class MonitorStep extends DatabaseProperty {
}
}
if (monitorType === MonitorType.Kubernetes) {
if (!value.data.kubernetesMonitor) {
return "Kubernetes monitor configuration is required";
}
if (!value.data.kubernetesMonitor.clusterIdentifier) {
return "Kubernetes cluster is required";
}
}
return null;
}
@@ -461,6 +487,11 @@ export default class MonitorStep extends DatabaseProperty {
this.data.externalStatusPageMonitor,
)
: undefined,
kubernetesMonitor: this.data.kubernetesMonitor
? MonitorStepKubernetesMonitorUtil.toJSON(
this.data.kubernetesMonitor,
)
: undefined,
},
});
}
@@ -575,6 +606,9 @@ export default class MonitorStep extends DatabaseProperty {
externalStatusPageMonitor: json["externalStatusPageMonitor"]
? (json["externalStatusPageMonitor"] as JSONObject)
: undefined,
kubernetesMonitor: json["kubernetesMonitor"]
? (json["kubernetesMonitor"] as JSONObject)
: undefined,
}) as any;
return monitorStep;
@@ -603,6 +637,7 @@ export default class MonitorStep extends DatabaseProperty {
dnsMonitor: Zod.any().optional(),
domainMonitor: Zod.any().optional(),
externalStatusPageMonitor: Zod.any().optional(),
kubernetesMonitor: Zod.any().optional(),
}).openapi({
type: "object",
example: {

View File

@@ -0,0 +1,50 @@
import { JSONObject } from "../JSON";
import MetricsViewConfig from "../Metrics/MetricsViewConfig";
import RollingTime from "../RollingTime/RollingTime";
export enum KubernetesResourceScope {
Cluster = "Cluster",
Namespace = "Namespace",
Workload = "Workload",
Node = "Node",
Pod = "Pod",
}
export interface KubernetesResourceFilters {
namespace?: string | undefined;
workloadType?: string | undefined; // deployment, statefulset, daemonset, job, cronjob
workloadName?: string | undefined;
nodeName?: string | undefined;
podName?: string | undefined;
}
export default interface MonitorStepKubernetesMonitor {
clusterIdentifier: string;
resourceScope: KubernetesResourceScope;
resourceFilters: KubernetesResourceFilters;
metricViewConfig: MetricsViewConfig;
rollingTime: RollingTime;
}
export class MonitorStepKubernetesMonitorUtil {
public static getDefault(): MonitorStepKubernetesMonitor {
return {
clusterIdentifier: "",
resourceScope: KubernetesResourceScope.Cluster,
resourceFilters: {},
metricViewConfig: {
queryConfigs: [],
formulaConfigs: [],
},
rollingTime: RollingTime.Past1Minute,
};
}
public static fromJSON(json: JSONObject): MonitorStepKubernetesMonitor {
return json as any as MonitorStepKubernetesMonitor;
}
public static toJSON(monitor: MonitorStepKubernetesMonitor): JSONObject {
return monitor as any as JSONObject;
}
}

View File

@@ -83,6 +83,10 @@ export class MonitorTypeHelper {
label: "Infrastructure",
monitorTypes: [MonitorType.Server, MonitorType.SNMP],
},
{
label: "Kubernetes",
monitorTypes: [MonitorType.Kubernetes],
},
{
label: "Telemetry",
monitorTypes: [
@@ -104,7 +108,8 @@ export class MonitorTypeHelper {
monitorType === MonitorType.Logs ||
monitorType === MonitorType.Metrics ||
monitorType === MonitorType.Traces ||
monitorType === MonitorType.Exceptions
monitorType === MonitorType.Exceptions ||
monitorType === MonitorType.Kubernetes
);
}
@@ -142,15 +147,13 @@ export class MonitorTypeHelper {
"This monitor type does the basic ping test of an endpoint.",
icon: IconProp.Signal,
},
/*
* {
* monitorType: MonitorType.Kubernetes,
* title: 'Kubernetes',
* description:
* 'This monitor types lets you monitor Kubernetes clusters.',
* icon: IconProp.Cube,
* },
*/
{
monitorType: MonitorType.Kubernetes,
title: "Kubernetes",
description:
"This monitor type lets you monitor Kubernetes clusters, workloads, nodes, and pods.",
icon: IconProp.Cube,
},
{
monitorType: MonitorType.IP,
title: "IP",
@@ -334,6 +337,7 @@ export class MonitorTypeHelper {
MonitorType.DNS,
MonitorType.Domain,
MonitorType.ExternalStatusPage,
MonitorType.Kubernetes,
];
}

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

@@ -40,22 +40,42 @@ const DictionaryOfStringsViewer: FunctionComponent<ComponentProps> = (
);
}, [props.value]);
if (data.length === 0) {
return (
<div className="text-gray-400 text-sm py-2">No items to display.</div>
);
}
return (
<div>
<div>
{data.map((item: Item, index: number) => {
return (
<div key={index} className="flex">
<div className="mr-1">
<div>{item.key}</div>
</div>
<div className="ml-1">
<div>{item.value}</div>
</div>
</div>
);
})}
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Key
</th>
<th className="px-4 py-2.5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Value
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-100">
{data.map((item: Item, index: number) => {
return (
<tr key={index} className="hover:bg-gray-50/50">
<td className="px-4 py-2 text-sm font-mono font-medium text-indigo-700 whitespace-nowrap">
{item.key}
</td>
<td className="px-4 py-2 text-sm font-mono text-gray-600 break-all">
{item.value || (
<span className="text-gray-400 italic">empty</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};

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

@@ -1,6 +1,7 @@
import Icon from "../Icon/Icon";
import IconProp from "../../../Types/Icon/IconProp";
import TinyFormDocumentation from "../TinyFormDocumentation/TinyFormDocumentation";
import DOMPurify from "dompurify";
import React, {
FunctionComponent,
ReactElement,
@@ -416,9 +417,11 @@ const MarkdownEditor: FunctionComponent<ComponentProps> = (
htmlContent = `<p class="mb-4">${htmlContent}</p>`;
}
const sanitizedContent: string = DOMPurify.sanitize(htmlContent);
return (
<div className="p-4 min-h-32 bg-white prose prose-sm max-w-none">
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />
<div dangerouslySetInnerHTML={{ __html: sanitizedContent }} />
</div>
);
};

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;

View File

@@ -3,6 +3,7 @@ import React, {
FunctionComponent,
ReactElement,
useEffect,
useRef,
useState,
} from "react";
@@ -14,19 +15,39 @@ export interface ComponentProps {
const Tabs: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [currentTab, setCurrentTab] = useState<Tab | null>(null);
const [currentTabName, setCurrentTabName] = useState<string | null>(null);
const hasInitialized = useRef<boolean>(false);
// Initialize current tab only once, or when the tab list names change
useEffect(() => {
setCurrentTab(props.tabs.length > 0 ? (props.tabs[0] as Tab) : null);
}, [props.tabs]);
const tabNames: Array<string> = props.tabs.map((t: Tab) => t.name);
if (!hasInitialized.current && props.tabs.length > 0) {
hasInitialized.current = true;
setCurrentTabName(props.tabs[0]!.name);
return;
}
// If current tab no longer exists in the list, reset to first
if (currentTabName && !tabNames.includes(currentTabName)) {
setCurrentTabName(
props.tabs.length > 0 ? props.tabs[0]!.name : null,
);
}
}, [props.tabs.map((t: Tab) => t.name).join(",")]);
// Find the current tab object by name
const currentTab: Tab | undefined = props.tabs.find(
(t: Tab) => t.name === currentTabName,
);
useEffect(() => {
if (currentTab && props.onTabChange) {
props.onTabChange(currentTab);
}
}, [currentTab]);
}, [currentTabName]);
const tabPanelId: string = `tabpanel-${currentTab?.name || "default"}`;
const tabPanelId: string = `tabpanel-${currentTabName || "default"}`;
return (
<div>
@@ -41,9 +62,9 @@ const Tabs: FunctionComponent<ComponentProps> = (
key={tab.name}
tab={tab}
onClick={() => {
setCurrentTab(tab);
setCurrentTabName(tab.name);
}}
isSelected={tab === currentTab}
isSelected={tab.name === currentTabName}
tabPanelId={tabPanelId}
/>
);
@@ -52,7 +73,7 @@ const Tabs: FunctionComponent<ComponentProps> = (
<div
id={tabPanelId}
role="tabpanel"
aria-labelledby={`tab-${currentTab?.name || "default"}`}
aria-labelledby={`tab-${currentTabName || "default"}`}
className="mt-3 ml-1"
>
{currentTab && currentTab.children}

View File

@@ -80,7 +80,8 @@ export default class DropdownUtil {
public static getDropdownOptionsFromArray(
arr: Array<string>,
): Array<DropdownOption> {
return arr.map((item: string) => {
const uniqueArr: Array<string> = [...new Set(arr)];
return uniqueArr.map((item: string) => {
return {
label: item,
value: item,

View File

@@ -17,7 +17,7 @@ data:
kubeletstats:
collection_interval: {{ .Values.collectionInterval }}
auth_type: serviceAccount
endpoint: "https://${env:NODE_NAME}:10250"
endpoint: "https://${env:NODE_IP}:10250"
insecure_skip_verify: true
metric_groups:
- node
@@ -130,12 +130,30 @@ data:
- sources:
- from: connection
# Stamp with cluster name
# Stamp with cluster name and default service name
resource:
attributes:
- key: k8s.cluster.name
value: {{ .Values.clusterName | quote }}
action: upsert
- key: service.name
value: "kubernetes-agent-{{ .Values.clusterName }}"
action: upsert
# Set service.name from deployment/statefulset/daemonset name when available
transform:
log_statements:
- context: resource
statements:
- set(attributes["service.name"], attributes["k8s.deployment.name"]) where attributes["k8s.deployment.name"] != nil and attributes["k8s.deployment.name"] != ""
- set(attributes["service.name"], attributes["k8s.statefulset.name"]) where attributes["k8s.statefulset.name"] != nil and attributes["k8s.statefulset.name"] != "" and attributes["k8s.deployment.name"] == nil
- set(attributes["service.name"], attributes["k8s.daemonset.name"]) where attributes["k8s.daemonset.name"] != nil and attributes["k8s.daemonset.name"] != "" and attributes["k8s.deployment.name"] == nil and attributes["k8s.statefulset.name"] == nil
metric_statements:
- context: resource
statements:
- set(attributes["service.name"], attributes["k8s.deployment.name"]) where attributes["k8s.deployment.name"] != nil and attributes["k8s.deployment.name"] != ""
- set(attributes["service.name"], attributes["k8s.statefulset.name"]) where attributes["k8s.statefulset.name"] != nil and attributes["k8s.statefulset.name"] != "" and attributes["k8s.deployment.name"] == nil
- set(attributes["service.name"], attributes["k8s.daemonset.name"]) where attributes["k8s.daemonset.name"] != nil and attributes["k8s.daemonset.name"] != "" and attributes["k8s.deployment.name"] == nil and attributes["k8s.statefulset.name"] == nil
batch:
send_batch_size: 1024
@@ -163,6 +181,7 @@ data:
- memory_limiter
- k8sattributes
- resource
- transform
- batch
exporters:
- otlphttp
@@ -173,6 +192,7 @@ data:
- memory_limiter
- k8sattributes
- resource
- transform
- batch
exporters:
- otlphttp

View File

@@ -63,13 +63,28 @@ data:
mode: pull
interval: {{ .Values.resourceSpecs.interval }}
group: batch
- name: persistentvolumeclaims
mode: pull
interval: {{ .Values.resourceSpecs.interval }}
- name: persistentvolumes
mode: pull
interval: {{ .Values.resourceSpecs.interval }}
- name: horizontalpodautoscalers
mode: pull
interval: {{ .Values.resourceSpecs.interval }}
group: autoscaling
- name: verticalpodautoscalers
mode: pull
interval: {{ .Values.resourceSpecs.interval }}
group: autoscaling.k8s.io
{{- end }}
{{- if .Values.controlPlane.enabled }}
# Scrape control plane metrics via Prometheus endpoints
{{- if or .Values.controlPlane.enabled .Values.serviceMesh.enabled }}
# Scrape metrics via Prometheus endpoints (control plane and/or service mesh)
prometheus:
config:
scrape_configs:
{{- if .Values.controlPlane.enabled }}
- job_name: etcd
scheme: https
tls_config:
@@ -109,6 +124,53 @@ data:
- targets:
- {{ . | quote }}
{{- end }}
{{- end }}
{{- if and .Values.serviceMesh.enabled (eq .Values.serviceMesh.provider "istio") }}
- job_name: envoy-stats
metrics_path: /stats/prometheus
scrape_interval: {{ .Values.serviceMesh.istio.scrapeInterval }}
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_container_name]
action: keep
regex: istio-proxy
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_port, __meta_kubernetes_pod_ip]
action: replace
regex: (\d+);((([0-9]+?)(\.|$)){4})
replacement: $2:15090
target_label: __address__
- action: labelmap
regex: __meta_kubernetes_pod_label_(.+)
- source_labels: [__meta_kubernetes_namespace]
action: replace
target_label: namespace
- source_labels: [__meta_kubernetes_pod_name]
action: replace
target_label: pod_name
{{- end }}
{{- if and .Values.serviceMesh.enabled (eq .Values.serviceMesh.provider "linkerd") }}
- job_name: linkerd-proxy
metrics_path: /metrics
scrape_interval: {{ .Values.serviceMesh.linkerd.scrapeInterval }}
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_container_name]
action: keep
regex: linkerd-proxy
- source_labels: [__meta_kubernetes_pod_container_port_name]
action: keep
regex: linkerd-admin
- action: labelmap
regex: __meta_kubernetes_pod_label_(.+)
- source_labels: [__meta_kubernetes_namespace]
action: replace
target_label: namespace
- source_labels: [__meta_kubernetes_pod_name]
action: replace
target_label: pod_name
{{- end }}
{{- end }}
processors:
@@ -145,12 +207,15 @@ data:
- sources:
- from: connection
# Stamp all telemetry with the cluster name
# Stamp all telemetry with the cluster name and service name
resource:
attributes:
- key: k8s.cluster.name
value: {{ .Values.clusterName | quote }}
action: upsert
- key: service.name
value: "kubernetes-agent-{{ .Values.clusterName }}"
action: upsert
# Batch telemetry for efficient export
batch:
@@ -187,7 +252,7 @@ data:
metrics:
receivers:
- k8s_cluster
{{- if .Values.controlPlane.enabled }}
{{- if or .Values.controlPlane.enabled .Values.serviceMesh.enabled }}
- prometheus
{{- end }}
processors:

View File

@@ -34,6 +34,10 @@ spec:
valueFrom:
fieldRef:
fieldPath: spec.nodeName
- name: NODE_IP
valueFrom:
fieldRef:
fieldPath: status.hostIP
- name: ONEUPTIME_API_KEY
valueFrom:
secretKeyRef:

View File

@@ -66,6 +66,24 @@ rules:
resources:
- events
verbs: ["get", "list", "watch"]
- apiGroups: ["autoscaling.k8s.io"]
resources:
- verticalpodautoscalers
verbs: ["get", "list", "watch"]
{{- if and .Values.serviceMesh.enabled (eq .Values.serviceMesh.provider "istio") }}
- apiGroups: ["networking.istio.io"]
resources:
- virtualservices
- destinationrules
- gateways
- serviceentries
verbs: ["get", "list", "watch"]
- apiGroups: ["security.istio.io"]
resources:
- peerauthentications
- authorizationpolicies
verbs: ["get", "list", "watch"]
{{- end }}
# For kubeletstats receiver
- nonResourceURLs:
- /metrics

View File

@@ -80,6 +80,16 @@ resourceSpecs:
# Collection intervals
collectionInterval: 30s
# Service mesh observability (Istio / Linkerd sidecar metrics)
serviceMesh:
enabled: false
# Supported providers: "istio", "linkerd"
provider: "istio"
istio:
scrapeInterval: 15s
linkerd:
scrapeInterval: 15s
# Service account configuration
serviceAccount:
create: true

View File

@@ -14,6 +14,19 @@ This plan proposes a phased implementation to deliver first-class Kubernetes mon
- **Helm Chart** - OneUptime deploys on Kubernetes with KEDA auto-scaling support
- **OpenTelemetry Collector** - Deployed via Helm, accepts OTLP on ports 4317/4318
- **MonitorType.Kubernetes** - Enum value defined (but disabled and unimplemented)
- **Phase 1.1: kubernetes-agent Helm Chart** - Full chart with kubeletstats, k8s_cluster, k8sobjects receivers, DaemonSet for logs, RBAC, configmaps, secret management
- **Phase 1.2: KubernetesCluster Database Model & Auto-Discovery** - Full model with provider detection, otelCollectorStatus, cached counts; auto-discovery via `findOrCreateByClusterIdentifier()`; disconnect detection after 5 min
- **Phase 1.3: Kubernetes Observability Product (Dashboard/Routes)** - Full sidebar navigation, 40+ routes, cluster list with onboarding guide, breadcrumbs
- **Phase 1.4: Cluster Overview Page** - Overview tab with health summary and resource consumption insights
- **Phase 1.5: Pod and Container Resource Metrics** - Pod list page, pod detail page with metrics/logs/events/YAML/containers tabs, container detail pages
- **Phase 1.6: Node Health Monitoring** - Node list page, node detail page with resource utilization
- **Phase 1.7: Kubernetes Event Ingestion** - Events page with timeline, filtering by namespace/reason/kind
- **Phase 1.8: Control Plane Monitoring** - Control plane page, Prometheus receiver config for etcd/apiserver/scheduler/controller-manager
- **Phase 2.3: Kubernetes Resource Inventory** - Full resource pages for Deployments, StatefulSets, DaemonSets, Jobs, CronJobs, Namespaces, Nodes, PVs, PVCs with detail views
- **Phase 2.5: Namespace Resource Quota Monitoring** - Namespace detail page with quota information
- **Phase 3.1: Kubernetes Log Collection** - DaemonSet with filelog receiver in kubernetes-agent Helm chart, logs tab on pod detail pages
- **Phase 3.3: Kubernetes-to-Telemetry Correlation** - Metrics, Logs, Events, YAML, and Containers tabs on all resource detail pages
- **Phase 4.10: Live YAML / Resource Inspection** - YAML tab component showing live resource specs
---
@@ -44,7 +57,7 @@ Observability
Monitoring
├── Monitors
│ └── Create Monitor → Type: Kubernetes
│ └── Select Cluster → Select Resource Scope → Configure Conditions
│ └── Select Clusters → Select Resource Scope → Configure Conditions
├── Monitor Groups
```
@@ -178,281 +191,9 @@ There are two separate Helm charts. OneUptime's own OTel Collector is **not modi
---
## Phase 1: Foundation (P0) — Kubernetes Product & Metric Collection
## Phase 1: Foundation (P0) — COMPLETED
Without these, OneUptime cannot monitor any Kubernetes cluster. This phase creates the Kubernetes product in Observability, makes K8s metrics flow into the platform, and provides cluster visibility with multi-cluster support from the start.
### 1.1 `kubernetes-agent` Helm Chart — OTel Collector with K8s Receivers
**Current**: No Kubernetes-specific data collection. OneUptime's own OTel Collector only receives OTLP.
**Target**: A new `oneuptime/kubernetes-agent` Helm chart that users install in their clusters to collect K8s metrics, events, and logs and send them to OneUptime.
> **Important**: OneUptime's own OTel Collector (`OTelCollector/otel-collector-config.template.yaml`) is NOT modified. It stays as an OTLP receiver. All K8s collection lives in the new `kubernetes-agent` chart.
**Implementation**:
- Create the `oneuptime/kubernetes-agent` Helm chart with an OTel Collector Deployment configured with:
- `kubeletstats` receiver for node and pod resource metrics:
- CPU, memory, filesystem, network per node and per pod/container
- Collection interval: 30s
- Auth via serviceAccount token
- `k8s_cluster` receiver for cluster-level metrics from the Kubernetes API:
- Deployment, ReplicaSet, StatefulSet, DaemonSet replica counts and status
- Pod phase, container states (waiting/running/terminated with reasons)
- Node conditions (Ready, MemoryPressure, DiskPressure, PIDPressure)
- Namespace resource quotas and limit ranges
- HPA current/desired replicas
- `k8sobjects` receiver for Kubernetes events:
- Watch Events API for Warning and Normal events
- Ingest as logs with structured attributes (reason, involvedObject, message)
- `k8s_events` receiver as alternative lightweight event collection
- `k8sattributes` processor to enrich all telemetry with K8s metadata:
- Pod name, namespace, node, deployment, replicaset, labels, annotations
- `resource` processor to stamp all telemetry with `k8s.cluster.name` from Helm values
- `otlphttp` exporter pointing to the user's OneUptime instance (URL + API key from Helm values)
- Helm values for configuration:
- `oneuptime.url` (required) — OneUptime endpoint
- `oneuptime.apiKey` (required) — project API key
- `clusterName` (required) — unique cluster identifier
- `namespaceFilters.include` / `namespaceFilters.exclude` — limit which namespaces are monitored
- `controlPlane.enabled` — enable/disable control plane metric scraping
- `logs.enabled` — enable/disable pod log collection DaemonSet
- RBAC resources: ClusterRole (read-only K8s API access), ClusterRoleBinding, ServiceAccount
- Use `otel/opentelemetry-collector-contrib` image (already has all required receivers)
**Files to create**:
- `HelmChart/Public/kubernetes-agent/Chart.yaml` (new chart)
- `HelmChart/Public/kubernetes-agent/values.yaml` (default values)
- `HelmChart/Public/kubernetes-agent/templates/deployment.yaml` (OTel Collector Deployment)
- `HelmChart/Public/kubernetes-agent/templates/configmap-deployment.yaml` (OTel config with K8s receivers)
- `HelmChart/Public/kubernetes-agent/templates/rbac.yaml` (ClusterRole, ClusterRoleBinding, ServiceAccount)
- `HelmChart/Public/kubernetes-agent/templates/secret.yaml` (OneUptime API key)
### 1.2 KubernetesCluster Database Model & Auto-Discovery
**Current**: No concept of a Kubernetes cluster as a resource.
**Target**: KubernetesCluster as a first-class database model with auto-discovery from incoming telemetry.
**Implementation**:
- Create `KubernetesCluster` database model:
- `projectId`, `name`, `clusterIdentifier` (from `k8s.cluster.name` attribute)
- `provider` (EKS/GKE/AKS/self-managed/unknown — auto-detected from node labels)
- `otelCollectorStatus` (connected/disconnected based on metric recency)
- `lastSeenAt` (updated on every metric batch)
- `nodeCount`, `podCount`, `namespaceCount` (cached summary stats)
- Standard fields: `createdAt`, `deletedAt`, `labels`
- Auto-discovery service:
- When metrics arrive with a new `k8s.cluster.name` value, auto-create the cluster resource
- Update `lastSeenAt` and summary stats on each ingestion batch
- Mark cluster as disconnected if no metrics received for 5 minutes
- Manual cluster registration via UI as alternative to auto-discovery
- API endpoints for CRUD operations on KubernetesCluster
**Files to modify**:
- `Common/Models/DatabaseModels/KubernetesCluster.ts` (new)
- `Common/Server/Services/KubernetesClusterService.ts` (new)
- `Ingestor/API/OTelIngest.ts` (add cluster auto-discovery on metric ingestion)
- Database migration for `KubernetesCluster` table
### 1.3 Kubernetes Observability Product — Dashboard Navigation & Routes
**Current**: No Kubernetes section in the dashboard.
**Target**: Kubernetes as a standalone product in the Observability section with full navigation.
**Implementation**:
- Add "Kubernetes" to the Observability section in the dashboard sidebar, peer to Logs, Traces, Metrics, Services, Exceptions
- Route structure:
- `/dashboard/:projectId/kubernetes` — Clusters List
- `/dashboard/:projectId/kubernetes/:clusterId` — Cluster Overview
- `/dashboard/:projectId/kubernetes/:clusterId/namespaces` — Namespaces
- `/dashboard/:projectId/kubernetes/:clusterId/workloads` — Workloads
- `/dashboard/:projectId/kubernetes/:clusterId/pods` — Pods
- `/dashboard/:projectId/kubernetes/:clusterId/pods/:podId` — Pod Detail
- `/dashboard/:projectId/kubernetes/:clusterId/nodes` — Nodes
- `/dashboard/:projectId/kubernetes/:clusterId/nodes/:nodeId` — Node Detail
- `/dashboard/:projectId/kubernetes/:clusterId/events` — Events
- `/dashboard/:projectId/kubernetes/:clusterId/control-plane` — Control Plane
- Clusters List page:
- Table of all clusters in this project
- Columns: name, provider, status (connected/disconnected), nodes, pods, CPU%, memory%, last seen
- Click to drill into any cluster
- Empty state (no clusters yet) with onboarding guide:
- Step-by-step instructions to connect a cluster
- Pre-filled Helm install command with the project's API key and OneUptime endpoint:
```
helm repo add oneuptime https://helm.oneuptime.com
helm install oneuptime-kubernetes oneuptime/kubernetes-agent \
--set oneuptime.url=https://<your-oneuptime-host> \
--set oneuptime.apiKey=<project-api-key> \
--set clusterName=<your-cluster-name> \
--namespace oneuptime \
--create-namespace
```
- "Copy to clipboard" button for the Helm command
- Explanation of what the Helm chart deploys:
- OTel Collector Deployment (cluster metrics via `kubeletstats`, `k8s_cluster`, `k8sobjects` receivers)
- OTel Collector DaemonSet (pod log collection via `filelog` receiver — optional, enabled by default)
- ClusterRole + ClusterRoleBinding for K8s API access (read-only)
- ServiceAccount for the OTel Collector
- Helm values reference for common customizations:
- `clusterName` (required) — unique name for this cluster
- `namespaceFilters.include` / `namespaceFilters.exclude` — limit which namespaces are monitored
- `controlPlane.enabled` — enable/disable control plane metric scraping (default: true for self-managed, false for managed K8s)
- `logs.enabled` — enable/disable pod log collection (default: true)
- `logs.levelFilter` — minimum log level to collect (default: all)
- "Waiting for data..." indicator that auto-refreshes and transitions to the cluster list once metrics arrive
- Link to full documentation for advanced configuration
**Files to modify**:
- `App/FeatureSet/Dashboard/src/Utils/PageMap.ts` (add Kubernetes page routes)
- `App/FeatureSet/Dashboard/src/Routes/KubernetesRoutes.tsx` (new - Kubernetes routes)
- `App/FeatureSet/Dashboard/src/Pages/Kubernetes/SideMenu.tsx` (new - Kubernetes nav)
- `App/FeatureSet/Dashboard/src/Pages/Kubernetes/` (new directory)
- `App/FeatureSet/Dashboard/src/Pages/Kubernetes/Clusters.tsx` (new - cluster list)
- `App/FeatureSet/Dashboard/src/Pages/Kubernetes/ClusterOverview.tsx` (new)
- `App/FeatureSet/Dashboard/src/Pages/Kubernetes/ClusterSideMenu.tsx` (new - cluster detail sidebar)
### 1.4 Cluster Overview Page
**Current**: No cluster-level visibility.
**Target**: At-a-glance cluster health showing key indicators.
**Implementation**:
- Summary cards: total nodes, total pods (running/pending/failed), namespaces, deployments
- Resource utilization gauges: cluster-wide CPU and memory usage vs capacity (requests, limits, actual)
- Node health table: nodes with conditions (Ready, MemoryPressure, DiskPressure)
- Pod status distribution: pie/bar chart of pod phases (Running/Pending/Succeeded/Failed/Unknown)
- Top consumers: top 10 pods by CPU, top 10 by memory
- Recent events: stream of Kubernetes Warning events
- Container restarts: pods with highest restart counts
- Control plane health summary: etcd leader status, API server error rate, scheduler pending pods
**Files to modify**:
- `App/FeatureSet/Dashboard/src/Pages/Kubernetes/ClusterOverview.tsx` (new)
- `App/FeatureSet/Dashboard/src/Components/Kubernetes/ClusterSummaryCards.tsx` (new)
- `App/FeatureSet/Dashboard/src/Components/Kubernetes/ResourceUtilizationGauge.tsx` (new)
### 1.5 Pod and Container Resource Metrics
**Current**: No container-level visibility.
**Target**: Detailed resource metrics for every pod and container with drill-down.
**Implementation**:
- Ensure the following kubeletstats metrics are ingested and queryable:
- `k8s.pod.cpu.utilization`, `k8s.pod.memory.usage`, `k8s.pod.memory.rss`
- `k8s.pod.network.io` (rx/tx bytes), `k8s.pod.filesystem.usage`
- `container.cpu.utilization`, `container.memory.usage`, `container.restarts`
- Pod list page: table with namespace, pod name, phase, restarts, CPU%, memory%, age
- Filterable by namespace, phase, workload
- Pod detail page:
- Resource usage time-series charts (CPU, memory, network, filesystem)
- Container statuses (waiting/running/terminated with reasons)
- Events for this pod
- Links to associated logs, traces (via K8s metadata correlation)
- Resource efficiency: actual usage vs requests vs limits per pod/container
**Files to modify**:
- `App/FeatureSet/Dashboard/src/Pages/Kubernetes/Pods.tsx` (new)
- `App/FeatureSet/Dashboard/src/Pages/Kubernetes/PodDetail.tsx` (new)
### 1.6 Node Health Monitoring
**Current**: No node-level metrics.
**Target**: Per-node resource utilization, conditions, and capacity tracking.
**Implementation**:
- Ingest node metrics via kubeletstats receiver:
- `k8s.node.cpu.utilization`, `k8s.node.memory.usage`, `k8s.node.memory.available`
- `k8s.node.filesystem.usage`, `k8s.node.filesystem.capacity`
- `k8s.node.network.io`, `k8s.node.condition` (Ready, MemoryPressure, etc.)
- Node list page: table with all nodes showing CPU%, memory%, disk%, conditions, pod count
- Node detail page: time-series charts for resource usage, pod list on node, events
- Node capacity planning: show allocatable vs requested vs used per node
**Files to modify**:
- `App/FeatureSet/Dashboard/src/Pages/Kubernetes/Nodes.tsx` (new)
- `App/FeatureSet/Dashboard/src/Pages/Kubernetes/NodeDetail.tsx` (new)
### 1.7 Kubernetes Event Ingestion
**Current**: No Kubernetes event collection.
**Target**: Ingest and surface Kubernetes events as structured logs with correlation to resources.
**Implementation**:
- Configure `k8sobjects` receiver to watch Kubernetes Events
- Map events to structured log entries:
- `severity` from event type (Warning -> WARN, Normal -> INFO)
- `body` from event message
- Attributes: `k8s.event.reason`, `k8s.event.count`, `k8s.object.kind`, `k8s.object.name`, `k8s.namespace.name`
- Create a dedicated "Events" view within each cluster:
- Filterable by namespace, event reason, object kind
- Timeline visualization showing event frequency
- Link events to related pods/deployments/nodes
- Alert on specific event patterns (e.g., repeated FailedScheduling, FailedMount)
**Files to modify**:
- `HelmChart/Public/kubernetes-agent/templates/configmap-deployment.yaml` (k8sobjects receiver already configured in 1.1)
- `App/FeatureSet/Dashboard/src/Pages/Kubernetes/Events.tsx` (new)
### 1.8 Control Plane Monitoring (etcd, API Server, Scheduler, Controller Manager)
**Current**: No control plane visibility. Cluster-level failures (etcd latency, API server overload, scheduler backlog) are invisible.
**Target**: Full control plane observability covering etcd, kube-apiserver, kube-scheduler, and kube-controller-manager.
**Implementation**:
- Add `prometheus` receiver to the `kubernetes-agent` OTel Collector config to scrape control plane metrics endpoints:
- **etcd** (typically `:2379/metrics`):
- `etcd_server_has_leader` — alert immediately if 0 (no leader elected)
- `etcd_server_leader_changes_seen_total` — rate of leader elections (frequent changes indicate instability)
- `etcd_disk_wal_fsync_duration_seconds` — WAL fsync latency (high values cause write stalls)
- `etcd_disk_backend_commit_duration_seconds` — backend commit latency
- `etcd_mvcc_db_total_size_in_bytes` — database size (alert when approaching quota, default 2GB)
- `etcd_network_peer_round_trip_time_seconds` — peer-to-peer RTT (network health between etcd members)
- `etcd_server_proposals_failed_total` — failed Raft proposals
- `etcd_server_proposals_pending` — pending proposals (backpressure indicator)
- `etcd_debugging_mvcc_keys_total` — total key count (cardinality tracking)
- `etcd_mvcc_db_total_size_in_use_in_bytes` — actual data size vs total DB size (fragmentation indicator)
- `grpc_server_handled_total` — gRPC request rate and error rate to etcd
- **kube-apiserver** (typically `:6443/metrics`):
- `apiserver_request_total` — request rate by verb, resource, and response code
- `apiserver_request_duration_seconds` — request latency by verb and resource
- `apiserver_current_inflight_requests` — concurrent in-flight requests (throttling indicator)
- `apiserver_dropped_requests_total` — dropped requests due to throttling
- `apiserver_storage_objects` — object count per resource type in etcd
- `apiserver_admission_webhook_rejection_count` — webhook rejections
- `workqueue_depth` — controller work queue depth (backlog indicator)
- `workqueue_adds_total` — work queue processing rate
- **kube-scheduler** (typically `:10259/metrics`):
- `scheduler_pending_pods` — pods waiting to be scheduled (by queue)
- `scheduler_scheduling_attempt_duration_seconds` — scheduling latency
- `scheduler_schedule_attempts_total` — scheduling attempts by result (scheduled/unschedulable/error)
- `scheduler_preemption_attempts_total` — preemption frequency
- **kube-controller-manager** (typically `:10257/metrics`):
- `workqueue_depth` — per-controller queue depth
- `workqueue_retries_total` — retry rate (indicator of failing reconciliation)
- `node_collector_evictions_total` — node eviction count
- Handle access modes for different cluster types:
- Self-managed clusters: direct scrape with TLS client certs
- Managed clusters (EKS/GKE/AKS): use kube-proxy or metrics-server where control plane metrics are exposed; document limitations per provider
- `kubernetes-agent` Helm values for toggling control plane monitoring and configuring endpoints
- Control Plane page within cluster detail:
- etcd: leader status, DB size gauge, WAL/commit latency, peer RTT, key count, proposal rates
- API Server: request rate by verb, error rate, latency heatmap, inflight requests, throttling events
- Scheduler: pending pods, scheduling latency, attempt success/failure rates
- Controller Manager: per-controller queue depth, retry rates, eviction counts
**Files to modify**:
- `HelmChart/Public/kubernetes-agent/templates/configmap-deployment.yaml` (add prometheus receiver with control plane scrape targets)
- `HelmChart/Public/kubernetes-agent/values.yaml` (add controlPlane config with endpoint overrides and TLS settings)
- `HelmChart/Public/kubernetes-agent/templates/rbac.yaml` (ensure access to control plane metrics endpoints)
- `App/FeatureSet/Dashboard/src/Pages/Kubernetes/ControlPlane.tsx` (new)
All Phase 1 items have been implemented. See the Completed section above for details.
---
@@ -511,32 +252,7 @@ Without these, OneUptime cannot monitor any Kubernetes cluster. This phase creat
- `App/FeatureSet/Dashboard/src/Pages/Kubernetes/AlertSetup.tsx` (new - guided alert configuration)
- `Worker/Jobs/TelemetryMonitor/MonitorTelemetryMonitor.ts` (support K8s-specific criteria evaluation)
### 2.3 Kubernetes Resource Inventory
**Current**: No visibility into K8s resource state.
**Target**: Live inventory of Kubernetes resources with health status.
**Implementation**:
- Create a `KubernetesResource` model stored in ClickHouse (or PostgreSQL depending on query patterns):
- Kind, name, namespace, labels, annotations, status, conditions, timestamps
- Updated via the `k8s_cluster` receiver or periodic API sync
- Resource pages within each cluster:
- **Deployments**: List with replica status (ready/desired), last update, strategy
- **StatefulSets**: Ordered pod status, PVC bindings
- **DaemonSets**: Node coverage, desired vs current vs ready
- **Services**: Type (ClusterIP/NodePort/LoadBalancer), endpoints, selector
- **Ingresses**: Host rules, backend services, TLS status
- **ConfigMaps/Secrets**: List with last-modified (secrets show metadata only, never values)
- **PVCs**: Bound PV, capacity, access modes, storage class
- Drill-down from any resource to its associated pods, events, and telemetry
**Files to modify**:
- `Common/Models/AnalyticsModels/KubernetesResource.ts` (new)
- `Telemetry/Services/KubernetesResourceService.ts` (new - sync K8s resources)
- `App/FeatureSet/Dashboard/src/Pages/Kubernetes/Resources/` (new - pages for each resource kind)
### 2.4 HPA and VPA Monitoring
### 2.3 HPA and VPA Monitoring (was 2.4)
**Current**: No autoscaler visibility.
**Target**: Track HPA/VPA behavior and scaling events.
@@ -555,52 +271,11 @@ Without these, OneUptime cannot monitor any Kubernetes cluster. This phase creat
**Files to modify**:
- `App/FeatureSet/Dashboard/src/Pages/Kubernetes/Autoscaling.tsx` (new)
### 2.5 Namespace Resource Quota Monitoring
**Current**: No quota tracking.
**Target**: Track resource quota usage per namespace and alert on approaching limits.
**Implementation**:
- Ingest quota metrics from `k8s_cluster` receiver:
- `k8s.resource_quota.hard.cpu`, `k8s.resource_quota.used.cpu`
- `k8s.resource_quota.hard.memory`, `k8s.resource_quota.used.memory`
- `k8s.resource_quota.hard.pods`, `k8s.resource_quota.used.pods`
- Namespace detail page showing quota utilization gauges
- Alert when any quota usage exceeds 80% (configurable threshold)
**Files to modify**:
- `App/FeatureSet/Dashboard/src/Pages/Kubernetes/NamespaceDetail.tsx` (new)
---
## Phase 3: Advanced Observability (P2) — Correlation & Deep Visibility
### 3.1 Kubernetes Log Collection
**Current**: Users can manually configure Fluentd to send logs. No built-in K8s log collection.
**Target**: Automated pod log collection via OTel Collector with K8s metadata enrichment.
**Implementation**:
- Add `filelog` receiver to the `kubernetes-agent` OTel Collector DaemonSet for collecting container logs from `/var/log/pods/`:
- Parse container runtime log format (Docker JSON, CRI)
- Extract pod name, namespace, container name from file path
- Enrich with K8s metadata via `k8sattributes` processor
- The DaemonSet is part of the `kubernetes-agent` Helm chart (separate from the Deployment that collects metrics)
- `kubernetes-agent` Helm values to configure:
- `logs.enabled` — toggle DaemonSet on/off (default: true)
- Namespace inclusion/exclusion filters
- Log level filtering (e.g., only collect WARN and above)
- Container name exclusion patterns
- Link pod logs in the Kubernetes pod detail page
**Files to modify**:
- `HelmChart/Public/kubernetes-agent/templates/daemonset.yaml` (new - DaemonSet for log collection)
- `HelmChart/Public/kubernetes-agent/templates/configmap-daemonset.yaml` (new - DaemonSet-specific config with filelog receiver)
- `HelmChart/Public/kubernetes-agent/values.yaml` (add logs.* configuration options)
### 3.2 Service Mesh Observability
### 3.1 Service Mesh Observability
**Current**: No service mesh integration.
**Target**: Ingest and visualize service mesh metrics from Istio, Linkerd, or similar.
@@ -619,26 +294,6 @@ Without these, OneUptime cannot monitor any Kubernetes cluster. This phase creat
- `HelmChart/Public/kubernetes-agent/templates/configmap-deployment.yaml` (add prometheus receiver for mesh metrics)
- `Common/Types/Dashboard/Templates/ServiceMesh.ts` (new - mesh dashboard templates)
### 3.3 Kubernetes-to-Telemetry Correlation
**Current**: K8s resources and telemetry (metrics, logs, traces) are separate.
**Target**: Click on any K8s resource to see correlated telemetry.
**Implementation**:
- From any pod/deployment/service page, show:
- **Metrics**: CPU, memory, network filtered to that resource
- **Logs**: Logs from containers in that pod, filtered by K8s metadata attributes
- **Traces**: Traces originating from or passing through that service
- **Events**: Kubernetes events for that resource
- Use `k8sattributes` processor enrichment to correlate:
- `k8s.pod.name`, `k8s.namespace.name`, `k8s.deployment.name` across all signals
- Deep link from incident timeline to K8s resource view
**Files to modify**:
- `App/FeatureSet/Dashboard/src/Pages/Kubernetes/PodDetail.tsx` (add telemetry correlation tabs)
- `App/FeatureSet/Dashboard/src/Components/Kubernetes/ResourceTelemetryPanel.tsx` (new - reusable correlation panel)
---
## Phase 4: Intelligence & Differentiation (P3) — Long-Term
@@ -728,17 +383,7 @@ Without these, OneUptime cannot monitor any Kubernetes cluster. This phase creat
- Show K8s-sourced incident details on the public status page
- No competitor offers this — this is a unique differentiator for OneUptime
### 4.10 Live YAML / Resource Inspection
**Current**: No ability to view actual K8s resource specs from within OneUptime.
**Target**: View live YAML definitions for any K8s object alongside its metrics and events.
- Fetch and display the current YAML spec for any K8s resource (Deployments, Services, Ingresses, ConfigMaps, PVCs, NetworkPolicies, CRDs)
- Diff view showing recent spec changes
- Correlate spec changes with metric anomalies
- Dynatrace shipped this in Jan 2026 — increasingly a buyer expectation
### 4.11 Topology / Service Dependency Map
### 4.10 Topology / Service Dependency Map
**Current**: No visual service-to-service dependency mapping.
**Target**: Auto-generated service dependency map derived from K8s metadata and network traffic.
@@ -825,7 +470,7 @@ OneUptime is the only platform that combines Kubernetes monitoring + incident ma
2. **AI/ML-driven root cause analysis** — Dynatrace (Davis AI) and Datadog (Watchdog) do this automatically. No AI component in our roadmap.
3. **Kubernetes-native incident automation** — Our all-in-one platform advantage is not leveraged. K8s failure → auto-incident → status page update → on-call notification should be a headline feature.
4. **Kubernetes-aware status pages** — No competitor has this. Automatically reflecting K8s health on public status pages is a unique differentiator we're not building.
5. **Live YAML / resource inspection** — Dynatrace shipped this Jan 2026. Increasingly a buyer expectation.
5. ~~**Live YAML / resource inspection**~~**DONE.** YAML tab implemented on all resource detail pages.
6. **Topology / service dependency map** — Datadog and Dynatrace both offer visual service maps. We only mention this briefly under network policy monitoring.
7. **Managed K8s provider integrations** — No EKS/GKE/AKS-specific plans. These providers expose different control plane metrics with unique quirks.
8. **Cost optimization at P3 is too late** — Competitors treat cost optimization as core. It's the easiest way to show ROI to buyers.
@@ -851,33 +496,16 @@ The roadmap achieves **feature parity** on core K8s monitoring by P2. This is ne
## Quick Wins (Can Ship This Week)
1. **Enable Kubernetes MonitorType** - Uncomment the Kubernetes entry in `getAllMonitorTypeProps()` and wire it to existing telemetry monitors
2. **Scaffold `kubernetes-agent` Helm chart** - Minimal chart with OTel Collector Deployment, `kubeletstats` + `k8s_cluster` receivers, and OTLP exporter to OneUptime
3. **Kubernetes dashboard template** - Create a basic cluster health dashboard using standard OTel K8s metric names
4. **K8s event alerting** - Use existing log monitors to alert on K8s Warning events once event ingestion is configured
5. **Document `kubernetes-agent` setup** - Installation guide with Helm commands, values reference, and troubleshooting
---
## Recommended Implementation Order
1. **Quick Wins** - Enable MonitorType, scaffold `kubernetes-agent` Helm chart, documentation
2. **Phase 1.1** - `kubernetes-agent` Helm chart with K8s receivers (prerequisite for everything else)
3. **Phase 1.2** - KubernetesCluster database model and auto-discovery (multi-cluster from day one)
4. **Phase 1.3** - Kubernetes Observability product — dashboard navigation and routes
5. **Phase 1.8** - Control plane monitoring — etcd, API server, scheduler, controller manager
6. **Phase 1.7** - Kubernetes event ingestion (high value, uses existing log infrastructure)
7. **Phase 1.4** - Cluster overview page
8. **Phase 1.5** - Pod and container resource metrics pages
9. **Phase 1.6** - Node health monitoring pages
10. **Phase 2.1** - Enable MonitorType.Kubernetes (makes K8s data actionable)
11. **Phase 2.2** - K8s-aware alert templates, including etcd/control plane alerts
12. **Phase 2.3** - Resource inventory pages
13. **Phase 2.5** - Namespace quota monitoring
14. **Phase 2.4** - HPA/VPA monitoring
15. **Phase 3.1** - K8s log collection via DaemonSet
16. **Phase 3.3** - K8s-to-telemetry correlation
17. **Phase 3.2** - Service mesh observability
18. **Phase 4.x** - Cost attribution, network policies, eBPF, compliance, GitOps, AI RCA, incident automation, status page automation
1. **Quick Win** - Enable MonitorType.Kubernetes (makes K8s data actionable)
2. **Phase 2.2** - K8s-aware alert templates, including etcd/control plane alerts
3. **Phase 2.3** - HPA/VPA monitoring
4. **Phase 3.1** - Service mesh observability
5. **Phase 4.x** - Cost attribution, network policies, eBPF, compliance, GitOps, AI RCA, incident automation, status page automation, topology map, managed provider integrations, deployment tracking
## Verification

View File

@@ -2274,9 +2274,9 @@
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.6",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz",
"integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==",
"version": "5.5.7",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.7.tgz",
"integrity": "sha512-LteOsISQ2GEiDHZch6L9hB0+MLoYVLToR7xotrzU0opCICBkxOPgHAy1HxAvtxfJNXDJpgAsQN30mkrfpO2Prg==",
"funding": [
{
"type": "github",
@@ -2287,7 +2287,7 @@
"dependencies": {
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.1.3",
"strnum": "^2.1.2"
"strnum": "^2.2.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -4870,9 +4870,9 @@
}
},
"node_modules/strnum": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.1.tgz",
"integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==",
"funding": [
{
"type": "github",

View File

@@ -42,6 +42,7 @@ import MonitorStepExceptionMonitor, {
} from "Common/Types/Monitor/MonitorStepExceptionMonitor";
import ExceptionInstanceService from "Common/Server/Services/ExceptionInstanceService";
import ExceptionInstance from "Common/Models/AnalyticsModels/ExceptionInstance";
import MonitorStepKubernetesMonitor from "Common/Types/Monitor/MonitorStepKubernetesMonitor";
RunCron(
"TelemetryMonitor:MonitorTelemetryMonitor",
@@ -60,6 +61,7 @@ RunCron(
MonitorType.Traces,
MonitorType.Metrics,
MonitorType.Exceptions,
MonitorType.Kubernetes,
]),
telemetryMonitorNextMonitorAt:
DatabaseQueryHelper.lessThanEqualToOrNull(
@@ -224,6 +226,14 @@ const monitorTelemetryMonitor: MonitorTelemetryMonitorFunction = async (data: {
});
}
if (monitorType === MonitorType.Kubernetes) {
return monitorKubernetes({
monitorStep,
monitorId,
projectId,
});
}
throw new BadDataException("Monitor type is not supported");
};
@@ -392,6 +402,125 @@ const monitorException: MonitorExceptionFunction = async (data: {
};
};
type MonitorKubernetesFunction = (data: {
monitorStep: MonitorStep;
monitorId: ObjectID;
projectId: ObjectID;
}) => Promise<MetricMonitorResponse>;
const monitorKubernetes: MonitorKubernetesFunction = async (data: {
monitorStep: MonitorStep;
monitorId: ObjectID;
projectId: ObjectID;
}): Promise<MetricMonitorResponse> => {
const kubernetesMonitorConfig: MonitorStepKubernetesMonitor | undefined =
data.monitorStep.data?.kubernetesMonitor;
if (!kubernetesMonitorConfig) {
throw new BadDataException("Kubernetes monitor config is missing");
}
const startAndEndDate: InBetween<Date> =
RollingTimeUtil.convertToStartAndEndDate(
kubernetesMonitorConfig.rollingTime || RollingTime.Past1Minute,
);
const finalResult: Array<AggregatedResult> = [];
for (const queryConfig of kubernetesMonitorConfig.metricViewConfig
.queryConfigs) {
const query: Query<Metric> = {
projectId: data.projectId,
time: startAndEndDate,
name: queryConfig.metricQueryData.filterData.metricName,
};
// Start with any user-defined attribute filters
const attributes: Dictionary<string> = {};
if (
queryConfig.metricQueryData &&
queryConfig.metricQueryData.filterData &&
queryConfig.metricQueryData.filterData.attributes &&
Object.keys(queryConfig.metricQueryData.filterData.attributes).length > 0
) {
Object.assign(
attributes,
queryConfig.metricQueryData.filterData.attributes,
);
}
// Add Kubernetes-specific attribute filters
if (kubernetesMonitorConfig.clusterIdentifier) {
attributes["k8s.cluster.name"] =
kubernetesMonitorConfig.clusterIdentifier;
}
if (kubernetesMonitorConfig.resourceFilters) {
const resourceFilters = kubernetesMonitorConfig.resourceFilters;
if (resourceFilters.namespace) {
attributes["k8s.namespace.name"] = resourceFilters.namespace;
}
if (resourceFilters.nodeName) {
attributes["k8s.node.name"] = resourceFilters.nodeName;
}
if (resourceFilters.podName) {
attributes["k8s.pod.name"] = resourceFilters.podName;
}
if (resourceFilters.workloadName && resourceFilters.workloadType) {
const workloadType: string =
resourceFilters.workloadType.toLowerCase();
attributes[`k8s.${workloadType}.name`] =
resourceFilters.workloadName;
}
}
if (Object.keys(attributes).length > 0) {
query.attributes = attributes;
}
const aggregatedResults: AggregatedResult =
await MetricService.aggregateBy({
query: query,
aggregationType:
(queryConfig.metricQueryData.filterData
.aggegationType as MetricsAggregationType) ||
MetricsAggregationType.Avg,
aggregateColumnName: "value",
aggregationTimestampColumnName: "time",
startTimestamp:
(startAndEndDate?.startValue as Date) ||
OneUptimeDate.getCurrentDate(),
endTimestamp:
(startAndEndDate?.endValue as Date) ||
OneUptimeDate.getCurrentDate(),
limit: LIMIT_PER_PROJECT,
skip: 0,
groupBy: queryConfig.metricQueryData.groupBy,
props: {
isRoot: true,
},
});
logger.debug("Kubernetes monitor aggregated results");
logger.debug(aggregatedResults);
finalResult.push(aggregatedResults);
}
return {
projectId: data.projectId,
metricViewConfig: kubernetesMonitorConfig.metricViewConfig,
startAndEndDate: startAndEndDate,
metricResult: finalResult,
monitorId: data.monitorId,
};
};
type MonitorLogsFunction = (data: {
monitorStep: MonitorStep;
monitorId: ObjectID;

6
package-lock.json generated
View File

@@ -4112,9 +4112,9 @@
}
},
"node_modules/flatted": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"license": "ISC"
},
"node_modules/for-each": {