From 69cda677745add6427334089361f06a21a596d8e Mon Sep 17 00:00:00 2001 From: Alan Conway Date: Fri, 15 May 2026 10:52:16 -0400 Subject: [PATCH] feat: display korrel8r markers in the graph Korrel8r can add "markers" to nodes to indicate special conditions. - updated korrel8r/korrel8r-openapi.yaml, includes markers in graph nodes. - updated topology nodes to display markers in graph. - mark node matching current view "selected", follow view changes. --- korrel8r/korrel8r-openapi.yaml | 33 ++++++- web/src/__tests__/types.spec.ts | 1 + .../components/topology/Korrel8rTopology.tsx | 90 ++++++++++++++----- .../components/topology/korrel8rtopology.css | 15 +++- web/src/korrel8r/client/index.ts | 1 + web/src/korrel8r/client/types.gen.ts | 22 +++++ web/src/korrel8r/types.ts | 16 +++- 7 files changed, 142 insertions(+), 36 deletions(-) diff --git a/korrel8r/korrel8r-openapi.yaml b/korrel8r/korrel8r-openapi.yaml index 2559cd6..36997e0 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,18 +560,24 @@ 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." + markers: + description: Markers found on data objects for this node. + type: array + items: + $ref: "#/components/schemas/MarkerCount" + x-go-type-skip-optional-pointer: true result: description: Serialized result contents, may be large. type: array @@ -591,6 +604,18 @@ components: x-oapi-codegen-extra-tags: jsonschema: "Query for correlation data in DOMAIN:CLASS:SELECTOR format." + MarkerCount: + description: Marker with number of instances found. + type: object + required: [marker] + properties: + marker: + description: Marker for correlation data. + type: string + count: + description: Number of markers found, omitted if none. + type: integer + Rule: type: object required: [name] diff --git a/web/src/__tests__/types.spec.ts b/web/src/__tests__/types.spec.ts index 9bdf8e9..2b745b9 100644 --- a/web/src/__tests__/types.spec.ts +++ b/web/src/__tests__/types.spec.ts @@ -147,6 +147,7 @@ describe('Node', () => { count: 5, }, ], + markers: [], }); }); diff --git a/web/src/components/topology/Korrel8rTopology.tsx b/web/src/components/topology/Korrel8rTopology.tsx index f36066a..9bbe280 100644 --- a/web/src/components/topology/Korrel8rTopology.tsx +++ b/web/src/components/topology/Korrel8rTopology.tsx @@ -1,4 +1,4 @@ -import { Badge, Title } from '@patternfly/react-core'; +import { Badge, Label, LabelGroup, Title } from '@patternfly/react-core'; import { action, ComponentFactory, @@ -17,7 +17,9 @@ import { Model, ModelKind, Node, + NodeModel, NodeShape, + NodeStatus, SELECTION_EVENT, TOP_TO_BOTTOM, TopologyControlBar, @@ -33,12 +35,13 @@ 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'; // DagreLayout with straight edges (no angular bendpoints). class StraightEdgeDagreLayout extends DagreLayout { @@ -56,14 +59,45 @@ const nodeLabel = (node: korrel8r.Node): string => { return capitalize(c.name); }; +const nodeStatusProps = ( + node: korrel8r.Node, +): { + nodeStatus?: NodeStatus; + showStatusDecorator?: boolean; + statusDecoratorTooltip?: React.ReactNode; +} => { + if (node.disabled) { + return { + showStatusDecorator: true, + statusDecoratorTooltip: node.disabled, + }; + } + if (node.hasMarkers) { + return { + nodeStatus: NodeStatus.warning, + showStatusDecorator: true, + statusDecoratorTooltip: ( + + {node.markers.map(({ marker, count }) => ( + + ))} + + ), + }; + } + return {}; +}; + 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 topologyNode = ( onSelect(e)} + {...nodeStatusProps(node)} > - {getIcon(node.class)} + {getIcon(node?.class)} ); - if (node.error) { - // Gray out, add error tool tip + if (node.disabled) { + // Wrapper to make node gray with error tool tip. return ( - {node.error}){topologyNode} + {node.disabled}){topologyNode} ); } @@ -104,28 +141,34 @@ 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) 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) { + if (node.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: ${node.disabled}`); + node.disabled = t('Unable to find Console Link'); + } else if (node.class.domain === 'log' && !loggingAvailable) { + node.disabled = t('Logging Plugin Disabled'); + } else if (node.class.domain === 'netflow' && !netobserveAvailable) { + node.disabled = t('Netflow Plugin Disabled'); } return { - id: newNode.id, + id: node.id, type: 'node', width: NODE_DIAMETER, height: NODE_DIAMETER, shape: NODE_SHAPE, - data: newNode, + data: node, }; }), [graph, loggingAvailable, netobserveAvailable, t], @@ -150,7 +193,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 +201,7 @@ export const Korrel8rTopology: FC<{ const nodeMenu = useCallback( (e: GraphElement): React.ReactElement[] => { - const node = e.getData(); + const node: korrel8r.Node = e.getData(); const menu = [ {node.class.toString()} @@ -173,9 +216,8 @@ export const Korrel8rTopology: FC<{ setSelectedIds([node.id]); navigator.clipboard.writeText(qc.query.toString()); }} - icon={{`${qc.count} `}} > - {`${qc.query.selector} `} + {qc.query.selector} {`${qc.count}`} , ), ); diff --git a/web/src/components/topology/korrel8rtopology.css b/web/src/components/topology/korrel8rtopology.css index e9297b3..b2da3d7 100644 --- a/web/src/components/topology/korrel8rtopology.css +++ b/web/src/components/topology/korrel8rtopology.css @@ -1,7 +1,3 @@ -.tp-plugin__topology_invalid_node { - cursor: not-allowed; -} - .tp-plugin__topology_node_badge rect { fill: var(--pf-t--global--color--brand--default); stroke: var(--pf-t--global--color--brand--default); @@ -10,3 +6,14 @@ .tp-plugin__topology_node_badge text { fill: var(--pf-t--global--text--color--on-brand--default); } + +.tp-plugin__topology_marker_badge { + --pf-v6-c-badge--BackgroundColor: var(--pf-t--global--color--brand--default); + --pf-v6-c-badge--Color: var(--pf-t--global--text--color--on-brand--default); +} + +.tp-plugin__topology_node--disabled { + opacity: 0.5; + filter: grayscale(100%); + pointer-events: auto; +} diff --git a/web/src/korrel8r/client/index.ts b/web/src/korrel8r/client/index.ts index af98ba1..d0923b0 100644 --- a/web/src/korrel8r/client/index.ts +++ b/web/src/korrel8r/client/index.ts @@ -57,6 +57,7 @@ export type { ListGoalsErrors, ListGoalsResponse, ListGoalsResponses, + MarkerCount, Neighbors, Node, Object, diff --git a/web/src/korrel8r/client/types.gen.ts b/web/src/korrel8r/client/types.gen.ts index c810752..32ac2e3 100644 --- a/web/src/korrel8r/client/types.gen.ts +++ b/web/src/korrel8r/client/types.gen.ts @@ -142,6 +142,10 @@ export type Node = { * Number of results for this class, after de-duplication. */ count?: number; + /** + * Markers found on data objects for this node. + */ + markers?: Array; /** * Serialized result contents, may be large. */ @@ -162,6 +166,20 @@ export type QueryCount = { query: Query; }; +/** + * Marker with number of instances found. + */ +export type MarkerCount = { + /** + * Marker for correlation data. + */ + marker: string; + /** + * Number of markers found, omitted if none. + */ + count?: number; +}; + /** * Rule is a correlation rule with a list of queries and results counts found during navigation. */ @@ -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..c81bfff 100644 --- a/web/src/korrel8r/types.ts +++ b/web/src/korrel8r/types.ts @@ -248,15 +248,18 @@ export const unixSeconds = (d: Date | undefined): number | undefined => { return Math.floor(unixMilliseconds(d) / 1000) || undefined; }; +export type MarkerCount = api.MarkerCount; + export class Node { id: string; count: number; class: Class; queries: Array; - error: Error; + markers: Array; + 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,9 +267,14 @@ 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 ?? []); + this.markers = node.markers; + } + + get hasMarkers(): boolean { + return this.markers?.length > 0; } } @@ -342,6 +350,6 @@ export class Graph { } node(id: string): Node { - return this.nodeByClass[id]; + return this.nodeByClass?.[id]; } }