From 88214313b91c96aa7dc3a3f6f987223b691ab79a Mon Sep 17 00:00:00 2001 From: Alan Conway Date: Fri, 15 May 2026 10:52:16 -0400 Subject: [PATCH] feat: display korrel8r status in the graph Korrel8r can add status to query results to indicate special conditions. "error"/"warning" korrel8r status is shown as patternfly "danger"/"warning" Any other non-empty korrel8r status is shown as patternfly "info" - updated korrel8r/korrel8r-openapi.yaml, includes status in graph nodes. - updated topology nodes to display status in graph. - mark node matching current view "selected", follow view changes. Other: - Updated devspace.yaml to ignore build churn in sync process. WIP: add "info" status to nodes for anything that's not warning/error. --- korrel8r/korrel8r-openapi.yaml | 33 +++- web/src/__tests__/status.spec.ts | 147 +++++++++++++++++ web/src/__tests__/types.spec.ts | 4 +- .../components/topology/Korrel8rTopology.tsx | 148 ++++++++++++++---- .../components/topology/korrel8rtopology.css | 26 ++- web/src/components/topology/status.ts | 49 ++++++ web/src/korrel8r/client/index.ts | 1 + web/src/korrel8r/client/types.gen.ts | 22 +++ web/src/korrel8r/types.ts | 10 +- 9 files changed, 400 insertions(+), 40 deletions(-) create mode 100644 web/src/__tests__/status.spec.ts create mode 100644 web/src/components/topology/status.ts diff --git a/korrel8r/korrel8r-openapi.yaml b/korrel8r/korrel8r-openapi.yaml index 2559cd6..0f833ba 100644 --- a/korrel8r/korrel8r-openapi.yaml +++ b/korrel8r/korrel8r-openapi.yaml @@ -17,6 +17,7 @@ info: name: Apache 2.0 url: https://github.com/korrel8r/korrel8r/blob/main/LICENSE version: v1alpha1 + x-korrel8r-version: "0.10.1-dev" externalDocs: url: https://korrel8r.github.io/korrel8r/ description: Korrel8r User Guide @@ -294,6 +295,13 @@ paths: required: true schema: $ref: "#/components/schemas/Query" + - name: constraint + description: Constrains the objects that will be included in results. + in: query + style: form + explode: true + schema: + $ref: "#/components/schemas/Constraint" responses: "200": description: OK @@ -399,7 +407,6 @@ components: Constraint: description: Constrains the objects that will be included in search results. type: object - x-go-type: korrel8r.Constraint properties: start: type: string @@ -553,16 +560,16 @@ components: x-oapi-codegen-extra-tags: jsonschema: "Full class name in DOMAIN:CLASS format." queries: - type: array - x-go-type-skip-optional-pointer: true description: Queries yielding results for this class. + type: array items: $ref: "#/components/schemas/QueryCount" + x-go-type-skip-optional-pointer: true x-oapi-codegen-extra-tags: jsonschema: "Queries yielding results for this class." count: - type: integer description: Number of results for this class, after de-duplication. + type: integer x-oapi-codegen-extra-tags: jsonschema: "Number of results for this class, after de-duplication." result: @@ -590,6 +597,24 @@ components: - $ref: "#/components/schemas/Query" x-oapi-codegen-extra-tags: jsonschema: "Query for correlation data in DOMAIN:CLASS:SELECTOR format." + statuses: + description: Statuses found on data objects for this query. + type: array + items: + $ref: "#/components/schemas/StatusCount" + x-go-type-skip-optional-pointer: true + + StatusCount: + description: Status with number of instances found. + type: object + required: [status] + properties: + status: + description: Status for correlation data. + type: string + count: + description: Number of instances found, omitted if none. + type: integer Rule: type: object diff --git a/web/src/__tests__/status.spec.ts b/web/src/__tests__/status.spec.ts new file mode 100644 index 0000000..908e89f --- /dev/null +++ b/web/src/__tests__/status.spec.ts @@ -0,0 +1,147 @@ +jest.mock('@patternfly/react-topology', () => ({ + NodeStatus: { + default: 'default', + info: 'info', + success: 'success', + warning: 'warning', + danger: 'danger', + }, +})); + +import { + mergeStatusCounts, + Status, + statusForNode, + statusName, + toStatus, +} from '../components/topology/status'; +import * as api from '../korrel8r/client'; +import * as korrel8r from '../korrel8r/types'; + +describe('Status enum', () => { + it('has ordered severity levels', () => { + expect(Status.info).toBeLessThan(Status.warning); + expect(Status.warning).toBeLessThan(Status.danger); + }); +}); + +describe('statusName', () => { + it.each([ + { status: Status.info, name: 'info' }, + { status: Status.warning, name: 'warning' }, + { status: Status.danger, name: 'danger' }, + ])('converts $name', ({ status, name }) => { + expect(statusName(status)).toEqual(name); + }); +}); + +describe('statusForNode', () => { + it.each([ + { status: Status.info, nodeStatus: 'info' }, + { status: Status.warning, nodeStatus: 'warning' }, + { status: Status.danger, nodeStatus: 'danger' }, + ])('converts $status to NodeStatus', ({ status, nodeStatus }) => { + expect(statusForNode(status)).toEqual(nodeStatus); + }); +}); + +describe('toStatus', () => { + it.each([ + { input: 'error', expected: Status.danger }, + { input: 'Error', expected: Status.danger }, + { input: 'ERROR', expected: Status.danger }, + { input: 'critical', expected: Status.danger }, + { input: 'Fatal', expected: Status.danger }, + { input: 'some error here', expected: Status.danger }, + { input: 'critically fatal', expected: Status.danger }, + { input: 'warn', expected: Status.warning }, + { input: 'warning', expected: Status.warning }, + { input: 'Warning', expected: Status.warning }, + { input: 'WARNING', expected: Status.warning }, + { input: 'info', expected: Status.info }, + { input: 'anything', expected: Status.info }, + ])('converts "$input" to $expected', ({ input, expected }) => { + expect(toStatus(input)).toEqual(expected); + }); + + it('returns undefined for empty string', () => { + expect(toStatus('')).toBeUndefined(); + }); +}); + +const makeNode = ( + queries: Array<{ query: string; statuses: Array<{ status: string; count: number }> }>, +): korrel8r.Node => { + const apiNode: api.Node = { + class: 'log:application', + count: 0, + queries: queries.map((q) => ({ + query: q.query, + count: q.statuses.reduce((sum, s) => sum + s.count, 0), + statuses: q.statuses, + })), + }; + return new korrel8r.Node(apiNode); +}; + +describe('mergeStatusCounts', () => { + it('returns empty counts and undefined status for a node with no statuses', () => { + const node = makeNode([{ query: 'log:application:{}', statuses: [] }]); + const [counts, status] = mergeStatusCounts(node); + expect(counts).toEqual([]); + expect(status).toBeUndefined(); + }); + + it('returns the most severe status', () => { + const node = makeNode([ + { + query: 'log:application:{}', + statuses: [ + { status: 'info', count: 1 }, + { status: 'error', count: 2 }, + { status: 'warning', count: 3 }, + ], + }, + ]); + const [, status] = mergeStatusCounts(node); + expect(status).toEqual(Status.danger); + }); + + it('merges counts for the same status across queries', () => { + const node = makeNode([ + { query: 'log:application:{}', statuses: [{ status: 'error', count: 5 }] }, + { query: 'log:infrastructure:{}', statuses: [{ status: 'error', count: 3 }] }, + ]); + const [counts] = mergeStatusCounts(node); + const errorCount = counts.find((c) => c.status === 'error'); + expect(errorCount?.count).toEqual(8); + }); + + it('handles a single status', () => { + const node = makeNode([ + { + query: 'log:application:{}', + statuses: [{ status: 'warning', count: 10 }], + }, + ]); + const [counts, status] = mergeStatusCounts(node); + expect(status).toEqual(Status.warning); + expect(counts).toEqual([{ status: 'warning', count: 10 }]); + }); + + it('skips entries with empty status', () => { + const node = makeNode([ + { + query: 'log:application:{}', + statuses: [ + { status: '', count: 5 }, + { status: 'info', count: 2 }, + ], + }, + ]); + const [counts, status] = mergeStatusCounts(node); + expect(status).toEqual(Status.info); + expect(counts).toHaveLength(1); + expect(counts[0].status).toEqual('info'); + }); +}); diff --git a/web/src/__tests__/types.spec.ts b/web/src/__tests__/types.spec.ts index 9bdf8e9..2e52ceb 100644 --- a/web/src/__tests__/types.spec.ts +++ b/web/src/__tests__/types.spec.ts @@ -141,10 +141,12 @@ describe('Node', () => { { query: { class: { domain: 'a', name: 'b' }, selector: 'c' }, count: 5, + statuses: [], }, { query: { class: { domain: 'a', name: 'b' }, selector: 'd' }, count: 5, + statuses: [], }, ], }); @@ -154,7 +156,7 @@ describe('Node', () => { expect(new Node({ class: 'foobar', count: 1 })).toEqual({ id: 'foobar', count: 1, - error: new TypeError('invalid class: foobar'), + disabled: 'invalid class: foobar', queries: [], }); }); diff --git a/web/src/components/topology/Korrel8rTopology.tsx b/web/src/components/topology/Korrel8rTopology.tsx index f36066a..4916a90 100644 --- a/web/src/components/topology/Korrel8rTopology.tsx +++ b/web/src/components/topology/Korrel8rTopology.tsx @@ -1,26 +1,33 @@ -import { Badge, Title } from '@patternfly/react-core'; +import { Badge, Label, LabelGroup, Title, Tooltip, TooltipPosition } from '@patternfly/react-core'; +import InfoCircleIcon from '@patternfly/react-icons/dist/dynamic/icons/info-circle-icon'; import { action, ComponentFactory, ContextMenuItem, createTopologyControlButtons, DagreLayout, + Decorator, + DEFAULT_DECORATOR_RADIUS, defaultControlButtonsOptions, DefaultEdge, DefaultGroup, DefaultNode, EdgeStyle, ElementModel, + getDefaultShapeDecoratorCenter, Graph, GraphComponent, GraphElement, Model, ModelKind, Node, + NodeModel, NodeShape, + NodeStatus, SELECTION_EVENT, TOP_TO_BOTTOM, TopologyControlBar, + TopologyQuadrant, TopologyView, Visualization, VisualizationProvider, @@ -33,12 +40,14 @@ import { withSelection, WithSelectionProps, } from '@patternfly/react-topology'; +import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useLocationQuery } from '../../hooks/useLocationQuery'; import { useNavigateToQuery } from '../../hooks/useNavigateToQuery'; import * as korrel8r from '../../korrel8r/types'; import { getIcon } from '../icons'; import './korrel8rtopology.css'; -import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { mergeStatusCounts, statusForNode, statusName, toStatus } from './status'; // DagreLayout with straight edges (no angular bendpoints). class StraightEdgeDagreLayout extends DagreLayout { @@ -56,14 +65,62 @@ const nodeLabel = (node: korrel8r.Node): string => { return capitalize(c.name); }; +const nodeBadge = (node: korrel8r.Node): string => { + if (node.queries?.length > 1) { + return `${node.queries[0].count}/${node.count}`; + } + return `${node?.count ?? ''}`; +}; + +const statusTooltip = (statusCounts: korrel8r.StatusCount[]): React.ReactNode => { + if (statusCounts.length === 0) return undefined; + return ( + + {statusCounts.map(({ status, count }) => ( + + ))} + + ); +}; + interface Korrel8rTopologyNodeProps { - element: Node; + element: Node; } const Korrel8rTopologyNode: FC< Korrel8rTopologyNodeProps & WithContextMenuProps & WithSelectionProps & WithDragNodeProps > = ({ element, onSelect, selected, onContextMenu, contextMenuOpen, dragNodeRef }) => { - const node = element.getData(); + const node: korrel8r.Node = element.getData(); + const decoratorRef = useRef(null); + const [statusCounts, status] = mergeStatusCounts(node); + const nodeStatus = node.disabled ? undefined : statusForNode(status); + const tooltip = node.disabled ? undefined : statusTooltip(statusCounts); + const isInfo = nodeStatus === NodeStatus.info; + + const infoDecorator = (() => { + if (!isInfo || !tooltip) return undefined; + const { x, y } = getDefaultShapeDecoratorCenter(TopologyQuadrant.upperLeft, element); + return ( + + onSelect(e)} + icon={ + + + + } + innerRef={decoratorRef} + /> + + ); + })(); + const topologyNode = ( onSelect(e)} + nodeStatus={nodeStatus} + showStatusDecorator={node.disabled ? true : !isInfo && !!nodeStatus} + statusDecoratorTooltip={node.disabled ? node.disabled : !isInfo ? tooltip : undefined} + attachments={infoDecorator} > - {getIcon(node.class)} + {getIcon(node?.class)} ); - if (node.error) { - // Gray out, add error tool tip + if (node.disabled) { return ( - - {node.error}){topologyNode} + + {node.disabled} + {topologyNode} ); } @@ -104,28 +167,38 @@ export const Korrel8rTopology: FC<{ }> = ({ graph, loggingAvailable, netobserveAvailable, constraint }) => { const { t } = useTranslation('plugin__troubleshooting-panel-console-plugin'); const navigateToQuery = useNavigateToQuery(); + const locationQuery = useLocationQuery(); const [selectedIds, setSelectedIds] = useState([]); - const nodes = useMemo( - () => + useEffect(() => { + if (!locationQuery) { + setSelectedIds([]); + return; + } + const id = locationQuery.class.toString(); + setSelectedIds(graph.node(id) ? [id] : []); + }, [graph, locationQuery]); + + const nodes: NodeModel[] = useMemo( + (): NodeModel[] => graph.nodes.map((node: korrel8r.Node) => { - const newNode = { ...node }; - if (newNode.error) { + const data = { ...node }; + if (data.disabled) { // eslint-disable-next-line no-console - console.warn(`korrel8r node: ${newNode.error}`); - newNode.error = Error(t('Unable to find Console Link')); - } else if (newNode.class.domain === 'log' && !loggingAvailable) { - newNode.error = Error(t('Logging Plugin Disabled')); - } else if (newNode.class.domain === 'netflow' && !netobserveAvailable) { - newNode.error = Error(t('Netflow Plugin Disabled')); + console.warn(`korrel8r node: ${data.disabled}`); + data.disabled = t('Unable to find Console Link'); + } else if (data.class.domain === 'log' && !loggingAvailable) { + data.disabled = t('Logging Plugin Disabled'); + } else if (data.class.domain === 'netflow' && !netobserveAvailable) { + data.disabled = t('Netflow Plugin Disabled'); } return { - id: newNode.id, + id: data.id, type: 'node', width: NODE_DIAMETER, height: NODE_DIAMETER, shape: NODE_SHAPE, - data: newNode, + data, }; }), [graph, loggingAvailable, netobserveAvailable, t], @@ -150,7 +223,7 @@ export const Korrel8rTopology: FC<{ const id = selected?.[0]; // Select only one at a time. setSelectedIds([id]); const node = graph.node(id); - if (!node || node.error) return; + if (!node || node.disabled) return; navigateToQuery(node.queries?.[0]?.query, constraint); }, [graph, navigateToQuery, setSelectedIds, constraint], @@ -158,7 +231,14 @@ export const Korrel8rTopology: FC<{ const nodeMenu = useCallback( (e: GraphElement): React.ReactElement[] => { - const node = e.getData(); + const node: korrel8r.Node = e.getData(); + if (!!node.disabled || !node.class) { + return [ + + {node.id} + , + ]; + } const menu = [ {node.class.toString()} @@ -173,9 +253,17 @@ export const Korrel8rTopology: FC<{ setSelectedIds([node.id]); navigator.clipboard.writeText(qc.query.toString()); }} - icon={{`${qc.count} `}} > - {`${qc.query.selector} `} + {`${qc.count}`} {qc.query.selector} + {qc.statuses?.length > 0 && ( + + {qc.statuses.map(({ status, count }) => ( + + ))} + + )} , ), ); @@ -252,7 +340,11 @@ export const Korrel8rTopology: FC<{ }, [controller]); return ( -
+
{ + return Status[s] as StatusName; +}; + +// Convert Status enum to NodeStatus +export const statusForNode = (s: Status): NodeStatus | undefined => { + const name = statusName(s); + return name ? NodeStatus[name] : undefined; +}; + +// Convert a korrel8r status string to a status enum value +export const toStatus = (ks: string): Status | undefined => { + if (ks.match(/error|critical|fatal/i)) return Status.danger; + if (ks.match(/warn(ing)?/i)) return Status.warning; + if (ks) return Status.info; + return undefined; +}; + +// Collect and merge all status counts for the node. +// Return merges status counts and the most severe status seen. +export const mergeStatusCounts = ( + node: korrel8r.Node, +): [korrel8r.StatusCount[], Status | undefined] => { + const m = new Map(); // Original status string, total count. + let s = 0; + node.queries.forEach((qc) => + qc.statuses.forEach((sc) => { + if (!sc.status) return; + m.set(sc.status, (m.get(sc.status) ?? 0) + sc.count); + s = Math.max(s, toStatus(sc.status)); + }), + ); + const sc = [...m.entries()].map(([status, count]) => ({ status, count })); + return [sc, s || undefined]; +}; diff --git a/web/src/korrel8r/client/index.ts b/web/src/korrel8r/client/index.ts index af98ba1..150f38f 100644 --- a/web/src/korrel8r/client/index.ts +++ b/web/src/korrel8r/client/index.ts @@ -57,6 +57,7 @@ export type { ListGoalsErrors, ListGoalsResponse, ListGoalsResponses, + StatusCount, Neighbors, Node, Object, diff --git a/web/src/korrel8r/client/types.gen.ts b/web/src/korrel8r/client/types.gen.ts index c810752..d1d21db 100644 --- a/web/src/korrel8r/client/types.gen.ts +++ b/web/src/korrel8r/client/types.gen.ts @@ -160,6 +160,24 @@ export type QueryCount = { * Query for correlation data. */ query: Query; + /** + * Statuses found on data objects for this query. + */ + statuses?: Array; +}; + +/** + * Status with number of instances found. + */ +export type StatusCount = { + /** + * Status for correlation data. + */ + status: string; + /** + * Number of instances found, omitted if none. + */ + count?: number; }; /** @@ -541,6 +559,10 @@ export type ObjectsData = { * Query string. */ query: Query; + /** + * Constrains the objects that will be included in results. + */ + constraint?: Constraint; }; url: '/objects'; }; diff --git a/web/src/korrel8r/types.ts b/web/src/korrel8r/types.ts index a70067a..b502f67 100644 --- a/web/src/korrel8r/types.ts +++ b/web/src/korrel8r/types.ts @@ -248,15 +248,17 @@ export const unixSeconds = (d: Date | undefined): number | undefined => { return Math.floor(unixMilliseconds(d) / 1000) || undefined; }; +export type StatusCount = api.StatusCount; + export class Node { id: string; count: number; class: Class; queries: Array; - error: Error; + disabled: string; /** Construct a type-safe node from an API node. - * Does not throw, sets the `error` field on error. + * Does not throw, sets the `disabled` field if there is an error. */ constructor(node: api.Node) { this.id = node.class; @@ -264,7 +266,7 @@ export class Node { try { this.class = Class.parse(node.class); } catch (e) { - this.error = e; + this.disabled = e?.message ?? e.toString(); } this.queries = QueryCount.array(node.queries ?? []); } @@ -281,6 +283,7 @@ export class Edge { export class QueryCount { query: Query; count: number; + statuses: Array; error: Error; /** @@ -290,6 +293,7 @@ export class QueryCount { try { this.count = qc.count; this.query = Query.parse(qc.query); + this.statuses = qc.statuses ?? []; } catch (e) { this.error = e; }