mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
Merge branch 'master' of https://github.com/OneUptime/oneuptime
This commit is contained in:
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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'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."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 (
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
209
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Alerts.tsx
Normal file
209
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/Alerts.tsx
Normal 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
|
||||
"Create Monitor" 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
233
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/HPADetail.tsx
Normal file
233
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/HPADetail.tsx
Normal 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;
|
||||
164
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/HPAs.tsx
Normal file
164
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/HPAs.tsx
Normal 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;
|
||||
@@ -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} ·{" "}
|
||||
{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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
218
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PVCDetail.tsx
Normal file
218
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PVCDetail.tsx
Normal 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;
|
||||
208
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PVDetail.tsx
Normal file
208
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/PVDetail.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
220
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/VPADetail.tsx
Normal file
220
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/VPADetail.tsx
Normal 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;
|
||||
140
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/VPAs.tsx
Normal file
140
App/FeatureSet/Dashboard/src/Pages/Kubernetes/View/VPAs.tsx
Normal 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;
|
||||
@@ -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(
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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, "<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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,7 +6,7 @@ enum CodeType {
|
||||
Markdown = "markdown",
|
||||
SQL = "sql",
|
||||
Text = "text",
|
||||
// TODO add more mime types.
|
||||
YAML = "yaml",
|
||||
}
|
||||
|
||||
export default CodeType;
|
||||
|
||||
@@ -16,4 +16,5 @@ export default interface MetricQueryConfigData {
|
||||
metricQueryData: MetricQueryData;
|
||||
getSeries?: ((data: AggregatedModel) => ChartSeries) | undefined;
|
||||
chartType?: MetricChartType | undefined;
|
||||
yAxisValueFormatter?: ((value: number) => string) | undefined;
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
706
Common/Types/Monitor/KubernetesAlertTemplates.ts
Normal file
706
Common/Types/Monitor/KubernetesAlertTemplates.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
344
Common/Types/Monitor/KubernetesMetricCatalog.ts
Normal file
344
Common/Types/Monitor/KubernetesMetricCatalog.ts
Normal 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"];
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
50
Common/Types/Monitor/MonitorStepKubernetesMonitor.ts
Normal file
50
Common/Types/Monitor/MonitorStepKubernetesMonitor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
71
Common/UI/Components/AlertBanner/AlertBanner.tsx
Normal file
71
Common/UI/Components/AlertBanner/AlertBanner.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
|
||||
export enum AlertBannerType {
|
||||
Success = "success",
|
||||
Warning = "warning",
|
||||
Danger = "danger",
|
||||
Info = "info",
|
||||
}
|
||||
|
||||
export interface ComponentProps {
|
||||
title: string;
|
||||
type: AlertBannerType;
|
||||
children?: ReactElement | undefined;
|
||||
rightElement?: ReactElement | undefined;
|
||||
className?: string | undefined;
|
||||
}
|
||||
|
||||
const bannerStyles: Record<
|
||||
AlertBannerType,
|
||||
{ container: string; dot: string; title: string }
|
||||
> = {
|
||||
[AlertBannerType.Success]: {
|
||||
container: "bg-emerald-50 border-emerald-200",
|
||||
dot: "bg-emerald-500",
|
||||
title: "text-emerald-800",
|
||||
},
|
||||
[AlertBannerType.Warning]: {
|
||||
container: "bg-amber-50 border-amber-200",
|
||||
dot: "bg-amber-500",
|
||||
title: "text-amber-800",
|
||||
},
|
||||
[AlertBannerType.Danger]: {
|
||||
container: "bg-red-50 border-red-200",
|
||||
dot: "bg-red-500",
|
||||
title: "text-red-800",
|
||||
},
|
||||
[AlertBannerType.Info]: {
|
||||
container: "bg-blue-50 border-blue-200",
|
||||
dot: "bg-blue-500",
|
||||
title: "text-blue-800",
|
||||
},
|
||||
};
|
||||
|
||||
const AlertBanner: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const styles: { container: string; dot: string; title: string } =
|
||||
bannerStyles[props.type];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border p-4 ${styles.container} ${props.className || ""}`}
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`inline-flex h-3 w-3 rounded-full ${styles.dot}`}
|
||||
/>
|
||||
<span className={`text-lg font-semibold ${styles.title}`}>
|
||||
{props.title}
|
||||
</span>
|
||||
</div>
|
||||
{props.rightElement && <div>{props.rightElement}</div>}
|
||||
</div>
|
||||
{props.children && <div className="mt-2">{props.children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertBanner;
|
||||
151
Common/UI/Components/ConditionsTable/ConditionsTable.tsx
Normal file
151
Common/UI/Components/ConditionsTable/ConditionsTable.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
import ExpandableText from "../ExpandableText/ExpandableText";
|
||||
|
||||
export interface Condition {
|
||||
type: string;
|
||||
status: string;
|
||||
reason?: string | undefined;
|
||||
message?: string | undefined;
|
||||
lastTransitionTime?: string | undefined;
|
||||
}
|
||||
|
||||
export interface ComponentProps {
|
||||
conditions: Array<Condition>;
|
||||
negativeTypes?: Array<string> | undefined;
|
||||
className?: string | undefined;
|
||||
}
|
||||
|
||||
// Default condition types where "True" is bad
|
||||
const defaultNegativeTypes: Array<string> = [
|
||||
"MemoryPressure",
|
||||
"DiskPressure",
|
||||
"PIDPressure",
|
||||
"NetworkUnavailable",
|
||||
];
|
||||
|
||||
function isConditionBad(
|
||||
condition: Condition,
|
||||
negativeTypes: Array<string>,
|
||||
): boolean {
|
||||
if (negativeTypes.includes(condition.type)) {
|
||||
return condition.status === "True";
|
||||
}
|
||||
return condition.status === "False";
|
||||
}
|
||||
|
||||
function getStatusStyle(
|
||||
condition: Condition,
|
||||
negativeTypes: Array<string>,
|
||||
): string {
|
||||
const isNegativeType: boolean = negativeTypes.includes(condition.type);
|
||||
if (condition.status === "True") {
|
||||
return isNegativeType
|
||||
? "bg-gradient-to-r from-red-50 to-red-100 text-red-800 ring-1 ring-inset ring-red-200/80"
|
||||
: "bg-gradient-to-r from-emerald-50 to-emerald-100 text-emerald-800 ring-1 ring-inset ring-emerald-200/80";
|
||||
}
|
||||
if (condition.status === "False") {
|
||||
return isNegativeType
|
||||
? "bg-gradient-to-r from-emerald-50 to-emerald-100 text-emerald-800 ring-1 ring-inset ring-emerald-200/80"
|
||||
: "bg-gradient-to-r from-red-50 to-red-100 text-red-800 ring-1 ring-inset ring-red-200/80";
|
||||
}
|
||||
return "bg-gradient-to-r from-amber-50 to-amber-100 text-amber-800 ring-1 ring-inset ring-amber-200/80";
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: string): string {
|
||||
if (!timestamp) {
|
||||
return "-";
|
||||
}
|
||||
const date: Date = new Date(timestamp);
|
||||
const now: Date = new Date();
|
||||
const diffMs: number = now.getTime() - date.getTime();
|
||||
if (diffMs < 0) {
|
||||
return timestamp;
|
||||
}
|
||||
const diffSec: number = Math.floor(diffMs / 1000);
|
||||
if (diffSec < 60) {
|
||||
return `${diffSec}s ago`;
|
||||
}
|
||||
const diffMin: number = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) {
|
||||
return `${diffMin}m ago`;
|
||||
}
|
||||
const diffHrs: number = Math.floor(diffMin / 60);
|
||||
if (diffHrs < 24) {
|
||||
return `${diffHrs}h ago`;
|
||||
}
|
||||
const diffDays: number = Math.floor(diffHrs / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
const ConditionsTable: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const negativeTypes: Array<string> =
|
||||
props.negativeTypes || defaultNegativeTypes;
|
||||
|
||||
if (props.conditions.length === 0) {
|
||||
return (
|
||||
<div className="text-gray-500 text-sm p-4">
|
||||
No conditions available.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`overflow-x-auto ${props.className || ""}`}>
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Reason
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Message
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Last Transition
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{props.conditions.map((condition: Condition, index: number) => {
|
||||
const isBad: boolean = isConditionBad(condition, negativeTypes);
|
||||
return (
|
||||
<tr key={index} className={isBad ? "bg-red-50/50" : ""}>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{condition.type}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm">
|
||||
<span
|
||||
className={`inline-flex px-2 py-0.5 text-xs font-semibold rounded-full ${getStatusStyle(condition, negativeTypes)}`}
|
||||
>
|
||||
{condition.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
|
||||
{condition.reason || "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm max-w-md">
|
||||
<ExpandableText text={condition.message || "-"} />
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
|
||||
<span title={condition.lastTransitionTime || ""}>
|
||||
{formatRelativeTime(condition.lastTransitionTime || "")}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConditionsTable;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
42
Common/UI/Components/ExpandableText/ExpandableText.tsx
Normal file
42
Common/UI/Components/ExpandableText/ExpandableText.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { FunctionComponent, ReactElement, useState } from "react";
|
||||
|
||||
export interface ComponentProps {
|
||||
text: string;
|
||||
maxLength?: number | undefined;
|
||||
className?: string | undefined;
|
||||
}
|
||||
|
||||
const ExpandableText: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const [isExpanded, setIsExpanded] = useState<boolean>(false);
|
||||
const maxLength: number = props.maxLength || 80;
|
||||
|
||||
if (!props.text || props.text === "-") {
|
||||
return <span className="text-gray-400">-</span>;
|
||||
}
|
||||
|
||||
const isLong: boolean = props.text.length > maxLength;
|
||||
|
||||
if (!isLong) {
|
||||
return (
|
||||
<span className={props.className || "text-gray-600"}>{props.text}</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={props.className || "text-gray-600"}>
|
||||
{isExpanded ? props.text : props.text.substring(0, maxLength) + "..."}
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
className="ml-1.5 text-xs text-indigo-600 hover:text-indigo-800 font-medium"
|
||||
>
|
||||
{isExpanded ? "Less" : "More"}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpandableText;
|
||||
60
Common/UI/Components/FilterButtons/FilterButtons.tsx
Normal file
60
Common/UI/Components/FilterButtons/FilterButtons.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
|
||||
export interface FilterButtonOption {
|
||||
label: string;
|
||||
value: string;
|
||||
badge?: number | undefined;
|
||||
}
|
||||
|
||||
export interface ComponentProps {
|
||||
options: Array<FilterButtonOption>;
|
||||
selectedValue: string;
|
||||
onSelect: (value: string) => void;
|
||||
className?: string | undefined;
|
||||
}
|
||||
|
||||
const FilterButtons: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex gap-1 ${props.className || ""}`}
|
||||
role="radiogroup"
|
||||
aria-label="Filter options"
|
||||
>
|
||||
{props.options.map((option: FilterButtonOption) => {
|
||||
const isActive: boolean = props.selectedValue === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
props.onSelect(option.value);
|
||||
}}
|
||||
className={`px-3 py-1.5 text-xs rounded-md font-medium transition-all duration-150 ${
|
||||
isActive
|
||||
? "bg-indigo-100 text-indigo-800 ring-1 ring-inset ring-indigo-200"
|
||||
: "bg-white text-gray-600 ring-1 ring-inset ring-gray-200 hover:bg-gray-50 hover:text-gray-800"
|
||||
}`}
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
>
|
||||
{option.label}
|
||||
{option.badge !== undefined && option.badge > 0 && (
|
||||
<span
|
||||
className={`ml-1.5 inline-flex min-w-[1.25rem] justify-center px-1 py-0 text-[10px] rounded-full ${
|
||||
isActive
|
||||
? "bg-indigo-200 text-indigo-900"
|
||||
: "bg-gray-100 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{option.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterButtons;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
58
Common/UI/Components/ResourceUsageBar/ResourceUsageBar.tsx
Normal file
58
Common/UI/Components/ResourceUsageBar/ResourceUsageBar.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
|
||||
export interface ComponentProps {
|
||||
label: string;
|
||||
value: number; // percentage 0-100
|
||||
valueLabel?: string | undefined;
|
||||
secondaryLabel?: string | undefined;
|
||||
heightClassName?: string | undefined;
|
||||
className?: string | undefined;
|
||||
labelWidthClassName?: string | undefined;
|
||||
}
|
||||
|
||||
function getBarColor(percent: number): string {
|
||||
if (percent > 80) {
|
||||
return "bg-red-500";
|
||||
}
|
||||
if (percent > 60) {
|
||||
return "bg-amber-500";
|
||||
}
|
||||
return "bg-emerald-500";
|
||||
}
|
||||
|
||||
const ResourceUsageBar: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const percent: number = Math.min(Math.max(props.value, 0), 100);
|
||||
const heightClass: string = props.heightClassName || "h-2";
|
||||
const labelWidthClass: string = props.labelWidthClassName || "w-40";
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${props.className || ""}`}>
|
||||
<div
|
||||
className={`${labelWidthClass} truncate text-sm text-gray-800 font-medium`}
|
||||
title={props.label}
|
||||
>
|
||||
{props.label}
|
||||
</div>
|
||||
{props.secondaryLabel && (
|
||||
<span className="inline-flex px-1.5 py-0.5 text-xs rounded bg-blue-50 text-blue-700">
|
||||
{props.secondaryLabel}
|
||||
</span>
|
||||
)}
|
||||
<div className={`flex-1 bg-gray-100 rounded-full ${heightClass}`}>
|
||||
<div
|
||||
className={`${heightClass} rounded-full transition-all duration-300 ${getBarColor(percent)}`}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{props.valueLabel && (
|
||||
<span className="text-xs text-gray-600 w-16 text-right font-medium tabular-nums">
|
||||
{props.valueLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourceUsageBar;
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
|
||||
export interface StackedProgressBarSegment {
|
||||
value: number;
|
||||
color: string; // Tailwind bg class, e.g. "bg-green-500"
|
||||
label: string;
|
||||
tooltip?: string | undefined;
|
||||
}
|
||||
|
||||
export interface ComponentProps {
|
||||
segments: Array<StackedProgressBarSegment>;
|
||||
totalValue?: number | undefined;
|
||||
heightClassName?: string | undefined;
|
||||
showLegend?: boolean | undefined;
|
||||
className?: string | undefined;
|
||||
}
|
||||
|
||||
const StackedProgressBar: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const total: number =
|
||||
props.totalValue ||
|
||||
props.segments.reduce(
|
||||
(sum: number, seg: StackedProgressBarSegment) => sum + seg.value,
|
||||
0,
|
||||
);
|
||||
|
||||
const heightClass: string = props.heightClassName || "h-4";
|
||||
const showLegend: boolean = props.showLegend !== false;
|
||||
|
||||
return (
|
||||
<div className={props.className || ""}>
|
||||
<div
|
||||
className={`flex ${heightClass} rounded-full overflow-hidden bg-gray-100`}
|
||||
role="progressbar"
|
||||
aria-label="Stacked progress bar"
|
||||
>
|
||||
{props.segments.map(
|
||||
(segment: StackedProgressBarSegment, index: number) => {
|
||||
if (segment.value <= 0 || total <= 0) {
|
||||
return null;
|
||||
}
|
||||
const widthPercent: number = (segment.value / total) * 100;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`${segment.color} ${heightClass} transition-all duration-300`}
|
||||
style={{ width: `${widthPercent}%` }}
|
||||
title={
|
||||
segment.tooltip || `${segment.label}: ${segment.value}`
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
{showLegend && (
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-1 mt-2.5">
|
||||
{props.segments
|
||||
.filter(
|
||||
(seg: StackedProgressBarSegment) => seg.value > 0,
|
||||
)
|
||||
.map(
|
||||
(
|
||||
segment: StackedProgressBarSegment,
|
||||
index: number,
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
className={`inline-block w-2.5 h-2.5 rounded-full ${segment.color}`}
|
||||
/>
|
||||
<span className="text-sm text-gray-600">
|
||||
{segment.label}{" "}
|
||||
<span className="font-medium text-gray-800">
|
||||
({segment.value})
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StackedProgressBar;
|
||||
44
Common/UI/Components/StatusBadge/StatusBadge.tsx
Normal file
44
Common/UI/Components/StatusBadge/StatusBadge.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
|
||||
export enum StatusBadgeType {
|
||||
Success = "success",
|
||||
Warning = "warning",
|
||||
Danger = "danger",
|
||||
Info = "info",
|
||||
Neutral = "neutral",
|
||||
}
|
||||
|
||||
export interface ComponentProps {
|
||||
text: string;
|
||||
type?: StatusBadgeType | undefined;
|
||||
className?: string | undefined;
|
||||
}
|
||||
|
||||
const statusStyles: Record<StatusBadgeType, string> = {
|
||||
[StatusBadgeType.Success]:
|
||||
"bg-gradient-to-r from-emerald-50 to-emerald-100 text-emerald-800 ring-1 ring-inset ring-emerald-200/80",
|
||||
[StatusBadgeType.Warning]:
|
||||
"bg-gradient-to-r from-amber-50 to-amber-100 text-amber-800 ring-1 ring-inset ring-amber-200/80",
|
||||
[StatusBadgeType.Danger]:
|
||||
"bg-gradient-to-r from-red-50 to-red-100 text-red-800 ring-1 ring-inset ring-red-200/80",
|
||||
[StatusBadgeType.Info]:
|
||||
"bg-gradient-to-r from-blue-50 to-blue-100 text-blue-800 ring-1 ring-inset ring-blue-200/80",
|
||||
[StatusBadgeType.Neutral]:
|
||||
"bg-gradient-to-r from-gray-50 to-gray-100 text-gray-700 ring-1 ring-inset ring-gray-200/80",
|
||||
};
|
||||
|
||||
const StatusBadge: FunctionComponent<ComponentProps> = (
|
||||
props: ComponentProps,
|
||||
): ReactElement => {
|
||||
const type: StatusBadgeType = props.type || StatusBadgeType.Neutral;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 text-xs font-semibold rounded-full ${statusStyles[type]} ${props.className || ""}`}
|
||||
>
|
||||
{props.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusBadge;
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -34,6 +34,10 @@ spec:
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: spec.nodeName
|
||||
- name: NODE_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.hostIP
|
||||
- name: ONEUPTIME_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
14
Probe/package-lock.json
generated
14
Probe/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
6
package-lock.json
generated
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user