feat: enhance handling of camelCase and snake_case in Kubernetes object parsing and telemetry

This commit is contained in:
Nawaz Dhandala
2026-03-24 20:11:42 +00:00
parent e12e3cfc08
commit c470d66725
9 changed files with 233 additions and 48 deletions

View File

@@ -31,21 +31,37 @@ export function getKvValue(
if (!val) {
return null;
}
// Handle both camelCase (JSON encoding) and snake_case (protobuf via protobufjs)
if (val["stringValue"] !== undefined) {
return val["stringValue"] as string;
}
if (val["string_value"] !== undefined) {
return val["string_value"] as string;
}
if (val["intValue"] !== undefined) {
return String(val["intValue"]);
}
if (val["int_value"] !== undefined) {
return String(val["int_value"]);
}
if (val["boolValue"] !== undefined) {
return String(val["boolValue"]);
}
if (val["bool_value"] !== undefined) {
return String(val["bool_value"]);
}
if (val["kvlistValue"]) {
return val["kvlistValue"] as JSONObject;
}
if (val["kvlist_value"]) {
return val["kvlist_value"] as JSONObject;
}
if (val["arrayValue"]) {
return val["arrayValue"] as JSONObject;
}
if (val["array_value"]) {
return val["array_value"] as JSONObject;
}
return null;
}
}
@@ -105,10 +121,16 @@ export function getKvListAsRecord(
if (key && val) {
if (val["stringValue"] !== undefined) {
result[key] = val["stringValue"] as string;
} else if (val["string_value"] !== undefined) {
result[key] = val["string_value"] as string;
} else if (val["intValue"] !== undefined) {
result[key] = String(val["intValue"]);
} else if (val["int_value"] !== undefined) {
result[key] = String(val["int_value"]);
} else if (val["boolValue"] !== undefined) {
result[key] = String(val["boolValue"]);
} else if (val["bool_value"] !== undefined) {
result[key] = String(val["bool_value"]);
}
}
}
@@ -135,9 +157,15 @@ export function getArrayValues(
if (item["kvlistValue"]) {
return item["kvlistValue"] as JSONObject;
}
if (item["kvlist_value"]) {
return item["kvlist_value"] as JSONObject;
}
if (item["stringValue"]) {
return item as JSONObject;
}
if (item["string_value"]) {
return item as JSONObject;
}
return null;
})
.filter(Boolean) as Array<JSONObject>;
@@ -148,24 +176,43 @@ export function getArrayValues(
* Handles stringValue, intValue, boolValue, kvlistValue, and arrayValue.
*/
function convertOtlpValue(valueWrapper: JSONObject): unknown {
// Handle both camelCase (JSON encoding) and snake_case (protobuf via protobufjs)
if (valueWrapper["stringValue"] !== undefined) {
return valueWrapper["stringValue"];
}
if (valueWrapper["string_value"] !== undefined) {
return valueWrapper["string_value"];
}
if (valueWrapper["intValue"] !== undefined) {
return Number(valueWrapper["intValue"]);
}
if (valueWrapper["int_value"] !== undefined) {
return Number(valueWrapper["int_value"]);
}
if (valueWrapper["boolValue"] !== undefined) {
return valueWrapper["boolValue"];
}
if (valueWrapper["bool_value"] !== undefined) {
return valueWrapper["bool_value"];
}
if (valueWrapper["doubleValue"] !== undefined) {
return Number(valueWrapper["doubleValue"]);
}
if (valueWrapper["double_value"] !== undefined) {
return Number(valueWrapper["double_value"]);
}
if (valueWrapper["kvlistValue"]) {
return kvListToPlainObject(valueWrapper["kvlistValue"] as JSONObject);
}
if (valueWrapper["kvlist_value"]) {
return kvListToPlainObject(valueWrapper["kvlist_value"] as JSONObject);
}
if (valueWrapper["arrayValue"]) {
return convertOtlpArray(valueWrapper["arrayValue"] as JSONObject);
}
if (valueWrapper["array_value"]) {
return convertOtlpArray(valueWrapper["array_value"] as JSONObject);
}
return null;
}
@@ -1861,9 +1908,9 @@ export function extractObjectFromLogBody(
): JSONObject | null {
try {
const bodyObj: JSONObject = JSON.parse(bodyString) as JSONObject;
const topKvList: JSONObject | undefined = bodyObj["kvlistValue"] as
| JSONObject
| undefined;
// Handle both camelCase (JSON encoding) and snake_case (protobuf via protobufjs)
const topKvList: JSONObject | undefined = (bodyObj["kvlistValue"] ||
bodyObj["kvlist_value"]) as JSONObject | undefined;
if (!topKvList) {
return null;
}
@@ -1886,6 +1933,15 @@ export function extractObjectFromLogBody(
return topKvList;
}
// Also check "metadata" as a fallback for objects without "kind"
const metadata: string | JSONObject | null = getKvValue(
topKvList,
"metadata",
);
if (metadata && typeof metadata !== "string") {
return topKvList;
}
return null;
} catch {
return null;

View File

@@ -45,12 +45,17 @@ import StatusBadge, {
StatusBadgeType,
} from "Common/UI/Components/StatusBadge/StatusBadge";
import ResourceUsageBar from "Common/UI/Components/ResourceUsageBar/ResourceUsageBar";
import Icon from "Common/UI/Components/Icon/Icon";
import IconProp from "Common/Types/Icon/IconProp";
interface ResourceLink {
title: string;
description: string;
pageMap: PageMap;
count?: number | undefined;
icon: IconProp;
iconBgClass: string;
iconTextClass: string;
}
function formatRelativeTime(timestamp: string): string {
@@ -318,64 +323,97 @@ const KubernetesClusterOverview: FunctionComponent<
const workloadLinks: Array<ResourceLink> = [
{
title: "Namespaces",
description: "View all namespaces",
description: "Logical partitions for resources",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACES,
count: namespaceCount > 0 ? namespaceCount : undefined,
icon: IconProp.Folder,
iconBgClass: "bg-indigo-100",
iconTextClass: "text-indigo-600",
},
{
title: "Pods",
description: "View all pods",
description: "Smallest deployable units",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_PODS,
count: podCount > 0 ? podCount : undefined,
icon: IconProp.Circle,
iconBgClass: "bg-emerald-100",
iconTextClass: "text-emerald-600",
},
{
title: "Deployments",
description: "View all deployments",
description: "Manage replica sets and rollouts",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_DEPLOYMENTS,
icon: IconProp.Layers,
iconBgClass: "bg-blue-100",
iconTextClass: "text-blue-600",
},
{
title: "StatefulSets",
description: "View all statefulsets",
description: "Ordered, stateful pod management",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_STATEFULSETS,
icon: IconProp.Database,
iconBgClass: "bg-purple-100",
iconTextClass: "text-purple-600",
},
{
title: "DaemonSets",
description: "View all daemonsets",
description: "Run pods on every node",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_DAEMONSETS,
icon: IconProp.Settings,
iconBgClass: "bg-orange-100",
iconTextClass: "text-orange-600",
},
{
title: "Jobs",
description: "View all jobs",
description: "Run-to-completion workloads",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_JOBS,
icon: IconProp.Play,
iconBgClass: "bg-amber-100",
iconTextClass: "text-amber-600",
},
{
title: "CronJobs",
description: "View all cron jobs",
description: "Scheduled recurring tasks",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_CRONJOBS,
icon: IconProp.Clock,
iconBgClass: "bg-teal-100",
iconTextClass: "text-teal-600",
},
];
const infraLinks: Array<ResourceLink> = [
{
title: "Nodes",
description: "View all nodes",
description: "Worker machines in the cluster",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_NODES,
count: nodeCount > 0 ? nodeCount : undefined,
icon: IconProp.Server,
iconBgClass: "bg-slate-100",
iconTextClass: "text-slate-600",
},
{
title: "Containers",
description: "View all containers",
description: "Running container instances",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_CONTAINERS,
icon: IconProp.Cube,
iconBgClass: "bg-cyan-100",
iconTextClass: "text-cyan-600",
},
{
title: "PVCs",
description: "View persistent volume claims",
description: "Persistent volume claims",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_PVCS,
icon: IconProp.Disc,
iconBgClass: "bg-rose-100",
iconTextClass: "text-rose-600",
},
{
title: "PVs",
description: "View persistent volumes",
description: "Persistent volumes",
pageMap: PageMap.KUBERNETES_CLUSTER_VIEW_PVS,
icon: IconProp.Disc,
iconBgClass: "bg-fuchsia-100",
iconTextClass: "text-fuchsia-600",
},
];
@@ -428,7 +466,7 @@ const KubernetesClusterOverview: FunctionComponent<
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">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 py-4 pr-4 pl-1">
{links.map((link: ResourceLink) => {
return (
<div
@@ -441,19 +479,29 @@ const KubernetesClusterOverview: FunctionComponent<
),
);
}}
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"
className="flex items-center gap-3 p-4 rounded-xl border border-gray-200 hover:border-indigo-300 hover:shadow-md transition-all duration-200 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
className={`flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center ${link.iconBgClass}`}
>
<Icon
icon={link.icon}
className={`h-5 w-5 ${link.iconTextClass}`}
/>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 group-hover:text-indigo-700 flex items-center justify-between">
<span>{link.title}</span>
{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 className="text-xs text-gray-500 mt-0.5">
{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>
);
})}
@@ -524,6 +572,14 @@ const KubernetesClusterOverview: FunctionComponent<
/>
<InfoCard
title="Nodes"
onClick={() => {
Navigation.navigate(
RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_NODES] as Route,
{ modelId: modelId },
),
);
}}
value={
<span className="text-2xl font-semibold">
{nodeCount.toString()}
@@ -537,6 +593,14 @@ const KubernetesClusterOverview: FunctionComponent<
/>
<InfoCard
title="Pods"
onClick={() => {
Navigation.navigate(
RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_PODS] as Route,
{ modelId: modelId },
),
);
}}
value={
<span className="text-2xl font-semibold">
{podCount.toString()}
@@ -545,6 +609,14 @@ const KubernetesClusterOverview: FunctionComponent<
/>
<InfoCard
title="Namespaces"
onClick={() => {
Navigation.navigate(
RouteUtil.populateRouteParams(
RouteMap[PageMap.KUBERNETES_CLUSTER_VIEW_NAMESPACES] as Route,
{ modelId: modelId },
),
);
}}
value={
<span className="text-2xl font-semibold">
{namespaceCount.toString()}

View File

@@ -17,6 +17,10 @@ import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
import KubernetesResourceUtils, {
KubernetesResource,
} from "../Utils/KubernetesResourceUtils";
import {
fetchK8sObjectsBatch,
KubernetesObjectType,
} from "../Utils/KubernetesObjectFetcher";
const KubernetesClusterViewLayout: FunctionComponent<
PageComponentProps
@@ -104,6 +108,27 @@ const KubernetesClusterViewLayout: FunctionComponent<
}),
]);
// Fetch PV/PVC/HPA/VPA counts from k8sobjects logs (they don't have k8s_cluster metrics)
const [pvcs, pvs, hpas, vpas]: Array<Map<string, KubernetesObjectType>> =
await Promise.all([
fetchK8sObjectsBatch({
clusterIdentifier: ci,
resourceType: "persistentvolumeclaims",
}),
fetchK8sObjectsBatch({
clusterIdentifier: ci,
resourceType: "persistentvolumes",
}),
fetchK8sObjectsBatch({
clusterIdentifier: ci,
resourceType: "horizontalpodautoscalers",
}),
fetchK8sObjectsBatch({
clusterIdentifier: ci,
resourceType: "verticalpodautoscalers",
}),
]);
setResourceCounts({
nodes: nodes?.length ?? 0,
pods: pods?.length ?? 0,
@@ -114,6 +139,10 @@ const KubernetesClusterViewLayout: FunctionComponent<
jobs: jobs?.length ?? 0,
cronJobs: cronJobs?.length ?? 0,
containers: containers?.length ?? 0,
pvcs: pvcs?.size ?? 0,
pvs: pvs?.size ?? 0,
hpas: hpas?.size ?? 0,
vpas: vpas?.size ?? 0,
});
} catch {
// Counts are supplementary, don't fail the layout

View File

@@ -211,33 +211,49 @@ export default class TelemetryUtil {
const jsonValue: JSONObject = value as JSONObject;
if (jsonValue && typeof jsonValue === "object") {
if (Object.prototype.hasOwnProperty.call(jsonValue, "stringValue")) {
const stringValue: JSONValue = jsonValue["stringValue"];
// Handle both camelCase (JSON encoding) and snake_case (protobuf via protobufjs)
if (
Object.prototype.hasOwnProperty.call(jsonValue, "stringValue") ||
Object.prototype.hasOwnProperty.call(jsonValue, "string_value")
) {
const stringValue: JSONValue =
jsonValue["stringValue"] ?? jsonValue["string_value"];
finalObj =
stringValue !== undefined && stringValue !== null
? (stringValue as string)
: "";
} else if (Object.prototype.hasOwnProperty.call(jsonValue, "intValue")) {
const intValue: JSONValue = jsonValue["intValue"];
} else if (
Object.prototype.hasOwnProperty.call(jsonValue, "intValue") ||
Object.prototype.hasOwnProperty.call(jsonValue, "int_value")
) {
const intValue: JSONValue =
jsonValue["intValue"] ?? jsonValue["int_value"];
if (intValue !== undefined && intValue !== null) {
finalObj = intValue as number;
}
} else if (
Object.prototype.hasOwnProperty.call(jsonValue, "doubleValue")
Object.prototype.hasOwnProperty.call(jsonValue, "doubleValue") ||
Object.prototype.hasOwnProperty.call(jsonValue, "double_value")
) {
const doubleValue: JSONValue = jsonValue["doubleValue"];
const doubleValue: JSONValue =
jsonValue["doubleValue"] ?? jsonValue["double_value"];
if (doubleValue !== undefined && doubleValue !== null) {
finalObj = doubleValue as number;
}
} else if (Object.prototype.hasOwnProperty.call(jsonValue, "boolValue")) {
finalObj = jsonValue["boolValue"] as boolean;
} else if (
jsonValue["arrayValue"] &&
(jsonValue["arrayValue"] as JSONObject)["values"]
Object.prototype.hasOwnProperty.call(jsonValue, "boolValue") ||
Object.prototype.hasOwnProperty.call(jsonValue, "bool_value")
) {
const values: JSONArray = (jsonValue["arrayValue"] as JSONObject)[
"values"
] as JSONArray;
finalObj = (jsonValue["boolValue"] ?? jsonValue["bool_value"]) as boolean;
} else if (
(jsonValue["arrayValue"] &&
(jsonValue["arrayValue"] as JSONObject)["values"]) ||
(jsonValue["array_value"] &&
(jsonValue["array_value"] as JSONObject)["values"])
) {
const arrayVal: JSONObject = (jsonValue["arrayValue"] ||
jsonValue["array_value"]) as JSONObject;
const values: JSONArray = arrayVal["values"] as JSONArray;
finalObj = values.map((v: JSONObject) => {
return this.getAttributeValues(
prefixKeysWithString,
@@ -290,17 +306,19 @@ export default class TelemetryUtil {
finalObj = flattenedFields;
} else if (
jsonValue["kvlistValue"] &&
(jsonValue["kvlistValue"] as JSONObject)["values"]
(jsonValue["kvlistValue"] &&
(jsonValue["kvlistValue"] as JSONObject)["values"]) ||
(jsonValue["kvlist_value"] &&
(jsonValue["kvlist_value"] as JSONObject)["values"])
) {
const values: JSONArray = (jsonValue["kvlistValue"] as JSONObject)[
"values"
] as JSONArray;
const kvlistVal: JSONObject = (jsonValue["kvlistValue"] ||
jsonValue["kvlist_value"]) as JSONObject;
const values: JSONArray = kvlistVal["values"] as JSONArray;
finalObj = this.getAttributes({
prefixKeysWithString,
items: values,
});
} else if ("nullValue" in jsonValue) {
} else if ("nullValue" in jsonValue || "null_value" in jsonValue) {
finalObj = null;
}
}

View File

@@ -6,6 +6,7 @@ export interface ComponentProps {
value: string | ReactElement;
className?: string;
textClassName?: string;
onClick?: (() => void) | undefined;
}
const InfoCard: FunctionComponent<ComponentProps> = (
@@ -13,7 +14,8 @@ const InfoCard: FunctionComponent<ComponentProps> = (
): ReactElement => {
return (
<div
className={`rounded-xl bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow duration-200 p-5 ${props.className || ""}`}
onClick={props.onClick}
className={`rounded-xl bg-white border border-gray-200 shadow-sm hover:shadow-md transition-shadow duration-200 p-5 ${props.onClick ? "cursor-pointer" : ""} ${props.className || ""}`}
>
<div className="mb-2">
<FieldLabelElement title={props.title} />

View File

@@ -73,11 +73,13 @@ data:
mode: pull
interval: {{ .Values.resourceSpecs.interval }}
group: autoscaling
{{- if .Values.resourceSpecs.vpa }}
- name: verticalpodautoscalers
mode: pull
interval: {{ .Values.resourceSpecs.interval }}
group: autoscaling.k8s.io
{{- end }}
{{- end }}
{{- if or .Values.controlPlane.enabled .Values.serviceMesh.enabled }}
# Scrape metrics via Prometheus endpoints (control plane and/or service mesh)

View File

@@ -195,6 +195,10 @@
"interval": {
"type": "string",
"description": "How often to pull resource specs (e.g., 300s, 5m)"
},
"vpa": {
"type": "boolean",
"description": "Enable VPA collection (requires VPA CRDs installed in the cluster)"
}
},
"additionalProperties": false

View File

@@ -76,6 +76,7 @@ logs:
resourceSpecs:
enabled: true
interval: 300s # How often to pull full resource specs (default: 5 minutes)
vpa: false # Enable VPA collection (requires VPA CRDs installed in the cluster)
# Collection intervals
collectionInterval: 30s

View File

@@ -278,9 +278,10 @@ export default class OtelLogsIngestService extends OtelIngestBaseService {
if (
logBody &&
typeof logBody === "object" &&
logBody["stringValue"]
(logBody["stringValue"] || logBody["string_value"])
) {
body = logBody["stringValue"] as string;
body = (logBody["stringValue"] ||
logBody["string_value"]) as string;
} else if (typeof log["body"] === "string") {
body = log["body"] as string;
} else {