blob: 2b6b6bcf220f9c6dd9a5083bc77ce5d0304dbabc [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const sumEntityMetrics = (obj: Record<string, number> | undefined): number =>
Object.values(obj || {}).reduce((a, b) => a + (Number(b) || 0), 0);
export const getEntityStatusTotals = (entity: Record<string, unknown> | undefined) => ({
active: sumEntityMetrics(entity?.entityActive as Record<string, number>),
shell: sumEntityMetrics(entity?.entityShell as Record<string, number>),
deleted: sumEntityMetrics(entity?.entityDeleted as Record<string, number>)
});
export interface EntityTypeDistributionItem {
name: string;
count: number;
active: number;
deleted: number;
/** Entity typedef names rolled into this bar (e.g. service type buckets). */
underlyingTypeNames?: string[];
}
export const getTaggedCount = (tag: Record<string, unknown> | undefined): number =>
sumEntityMetrics(tag?.tagEntities as Record<string, number>);
/** tagEntities values summed — counts tag–entity associations, not unique entities */
export const getTagEntityAssociationTotal = (tag: Record<string, unknown> | undefined): number =>
getTaggedCount(tag);
/**
* Number of classification types that have at least one active entity (each key in
* tagEntities with count > 0). Does not double-count the same entity across types.
*/
export const getClassificationTypesInUseCount = (tag: Record<string, unknown> | undefined): number => {
const tagEntities = tag?.tagEntities as Record<string, number> | undefined;
if (!tagEntities || typeof tagEntities !== "object") return 0;
return Object.values(tagEntities).filter((c) => (Number(c) || 0) > 0).length;
};
/** Classification names that have at least one entity assignment per tag metrics. */
export const getClassificationNamesInUseFromTag = (
tag: Record<string, unknown> | undefined
): Set<string> => {
const tagEntities = tag?.tagEntities as Record<string, number> | undefined;
const names = new Set<string>();
if (!tagEntities || typeof tagEntities !== "object") return names;
for (const [name, count] of Object.entries(tagEntities)) {
if ((Number(count) || 0) > 0) {
names.add(name);
}
}
return names;
};
/**
* Defined classification names (from type defs) with no entity assignments in metrics.
*/
export const getUnusedClassificationNames = (
definedNames: string[],
tag: Record<string, unknown> | undefined
): string[] => {
const inUse = getClassificationNamesInUseFromTag(tag);
return definedNames.filter((n) => !inUse.has(n)).sort((a, b) => a.localeCompare(b));
};
/** Parse stats from metrics general.stats (keys like "Notification:currentDay" -> { Notification: { currentDay } }) */
export const parseMetricsStats = (stats: Record<string, unknown> | undefined): Record<string, Record<string, unknown>> => {
const result: Record<string, Record<string, unknown>> = {};
if (!stats || typeof stats !== "object") return result;
for (const key of Object.keys(stats)) {
const parts = key.split(":");
const group = parts[0];
const subKey = parts[1];
if (!group || !subKey) continue;
if (!result[group]) result[group] = {};
result[group][subKey] = stats[key];
}
return result;
};
export interface MessageConsumptionItem {
period: string;
count: number;
creates: number;
updates: number;
deletes: number;
failed: number;
avgTime: number;
}
const getNotificationValue = (
notification: Record<string, unknown>,
key: string,
field: "creates" | "updates" | "deletes" | "failed" | "avgTime"
): number => {
const keyMap: Record<string, Record<string, string>> = {
total: { creates: "totalCreates", updates: "totalUpdates", deletes: "totalDeletes", failed: "totalFailed", avgTime: "totalAvgTime" },
currentHour: { creates: "currentHourEntityCreates", updates: "currentHourEntityUpdates", deletes: "currentHourEntityDeletes", failed: "currentHourFailed", avgTime: "currentHourAvgTime" },
previousHour: { creates: "previousHourEntityCreates", updates: "previousHourEntityUpdates", deletes: "previousHourEntityDeletes", failed: "previousHourFailed", avgTime: "previousHourAvgTime" },
currentDay: { creates: "currentDayEntityCreates", updates: "currentDayEntityUpdates", deletes: "currentDayEntityDeletes", failed: "currentDayFailed", avgTime: "currentDayAvgTime" },
previousDay: { creates: "previousDayEntityCreates", updates: "previousDayEntityUpdates", deletes: "previousDayEntityDeletes", failed: "previousDayFailed", avgTime: "previousDayAvgTime" }
};
const fullKey = keyMap[key]?.[field] ?? "";
return Number(notification[fullKey] ?? 0);
};
export interface GetMessageConsumptionDataOptions {
/** When false, omits the Total period (e.g. topic rows where total is shown as Processed). */
includeTotal?: boolean;
}
/** Extract message consumption data for bar chart from Notification stats */
export const getMessageConsumptionData = (
notification: Record<string, unknown> | undefined,
options?: GetMessageConsumptionDataOptions
): MessageConsumptionItem[] => {
if (!notification) return [];
const includeTotal = options?.includeTotal !== false;
const periods = [
{ key: "total", label: "Total" },
{ key: "currentHour", label: "Current Hour" },
{ key: "previousHour", label: "Previous Hour" },
{ key: "currentDay", label: "Current Day" },
{ key: "previousDay", label: "Previous Day" }
];
const selected = includeTotal ? periods : periods.filter((p) => p.key !== "total");
return selected.map(({ key, label }) => ({
period: label,
count: Number(notification[key] ?? 0),
creates: getNotificationValue(notification, key, "creates"),
updates: getNotificationValue(notification, key, "updates"),
deletes: getNotificationValue(notification, key, "deletes"),
failed: getNotificationValue(notification, key, "failed"),
avgTime: getNotificationValue(notification, key, "avgTime")
}));
};
/**
* Topic partition maps sometimes repeat `Notification:*` keys from the wire format.
* Strip the prefix so getMessageConsumptionData finds `currentHour`, `totalCreates`, etc.
*/
export const normalizeTopicMetricsRecord = (
topicDetail: Record<string, unknown> | undefined
): Record<string, unknown> => {
if (!topicDetail || typeof topicDetail !== "object") return {};
const out: Record<string, unknown> = { ...topicDetail };
for (const key of Object.keys(topicDetail)) {
if (!key.startsWith("Notification:")) continue;
const shortKey = key.slice("Notification:".length);
if (shortKey && out[shortKey] === undefined) {
out[shortKey] = topicDetail[key];
}
}
return out;
};
/**
* True when the payload includes at least one period bucket count (hour/day series).
* `total` alone is not enough — legacy rows often only mirror processedMessageCount.
*/
export const topicRowHasPeriodMetrics = (topicDetail: Record<string, unknown> | undefined): boolean => {
const n = normalizeTopicMetricsRecord(topicDetail);
return (
n.currentHour !== undefined ||
n.previousHour !== undefined ||
n.currentDay !== undefined ||
n.previousDay !== undefined
);
};
const omitTopicDetailsFromNotification = (
notification: Record<string, unknown> | undefined
): Record<string, unknown> => {
if (!notification || typeof notification !== "object") return {};
const { topicDetails: _omit, ...rest } = notification;
return rest;
};
/**
* One entry from `Notification.topicDetails` for getMessageConsumptionData.
* When the server omits per-period fields on partitions (legacy payload), merge in
* aggregate `Notification` (excluding `topicDetails`) so the chart matches /admin/metrics.
*/
export const buildTopicNotificationRecord = (
topicDetail: Record<string, unknown> | undefined,
options?: {
aggregateNotification?: Record<string, unknown> | undefined;
/** When true, fill period keys from aggregate if the topic row lacks them. */
useAggregateFallback?: boolean;
}
): Record<string, unknown> | undefined => {
if (!topicDetail || typeof topicDetail !== "object") {
if (options?.useAggregateFallback && options?.aggregateNotification) {
return omitTopicDetailsFromNotification(options.aggregateNotification);
}
return undefined;
}
const normalized = normalizeTopicMetricsRecord(topicDetail);
if (!options?.useAggregateFallback || !options?.aggregateNotification) {
return normalized;
}
const base = omitTopicDetailsFromNotification(options.aggregateNotification);
return { ...base, ...normalized };
};
/** Chart periods without Total (Processed column shows the total). */
export const getMessageConsumptionDataExcludingTotal = (
notification: Record<string, unknown> | undefined
): MessageConsumptionItem[] =>
getMessageConsumptionData(notification, { includeTotal: false });
export interface ClassificationDistributionItem {
name: string;
count: number;
}
/** Top classifications by entity count from tag.tagEntities */
export const getClassificationDistribution = (
tag: Record<string, unknown> | undefined,
topN = 5
): ClassificationDistributionItem[] => {
const tagEntities = tag?.tagEntities as Record<string, number> | undefined;
if (!tagEntities) return [];
return Object.entries(tagEntities)
.map(([name, count]) => ({ name, count: Number(count) || 0 }))
.sort((a, b) => b.count - a.count)
.slice(0, topN);
};