From 2471448e0f522c8391b16b1f8a4f9330043e1f12 Mon Sep 17 00:00:00 2001 From: Alan Conway Date: Wed, 6 May 2026 16:25:26 -0400 Subject: [PATCH 1/2] chore: Update korrel8r open API spec. Update to latest korrel8r API spec, minor changes to types. --- korrel8r/korrel8r-openapi.yaml | 214 ++++++++++++++++----------- web/src/__tests__/types.spec.ts | 2 +- web/src/korrel8r-client.ts | 93 +++++++++++- web/src/korrel8r/client/index.ts | 4 +- web/src/korrel8r/client/sdk.gen.ts | 12 +- web/src/korrel8r/client/types.gen.ts | 80 +++++----- web/src/korrel8r/types.ts | 21 +-- 7 files changed, 273 insertions(+), 153 deletions(-) diff --git a/korrel8r/korrel8r-openapi.yaml b/korrel8r/korrel8r-openapi.yaml index 06f2331..2559cd6 100644 --- a/korrel8r/korrel8r-openapi.yaml +++ b/korrel8r/korrel8r-openapi.yaml @@ -1,7 +1,15 @@ openapi: 3.0.1 info: title: Korrel8r REST API - description: Generate graphs showing correlations between resources and observability signals in a cluster. + description: > + Korrel8r correlates observability signals and resources in a Kubernetes cluster. + It connects data from different domains (logs, metrics, alerts, traces, Kubernetes resources) + by following correlation rules to build a graph of related objects. + + Korrel8r creates a separate session for each user with a unique HTTP Authorization header. + Configuration changes, console state, and store connections are isolated per session. + Requests without an Authorization header share a single default session. + contact: name: Project Korrel8r url: https://github.com/korrel8r/korrel8r @@ -15,13 +23,14 @@ externalDocs: servers: - url: /api/v1alpha1 tags: + - name: configure + description: Modify engine configuration (e.g. log verbosity). + - name: query + description: Query directly for data objects. + - name: correlate + description: Generate correlation graphs. - name: console - description: > - Korrel8r can act as a bridge between a GUI console and an AI agent. - The agent uses Korrel8r's MCP API to discover what is displayed in the console, - and to send new data to update what is displayed. - The console uses the /console REST APIs to provide information about the current display, - and to accept updates from the agent to change the display. + description: Bridge between a GUI console (REST) and an AI agent (MCP) paths: /config: @@ -42,6 +51,10 @@ paths: responses: "200": description: OK + content: + application/json: + schema: + type: object /domains: get: @@ -190,6 +203,7 @@ paths: # DEPRECATED - alternate spelling. /graphs/neighbours: post: + deprecated: true summary: Create a neighborhood graph around a start object to a given depth. description: > Specify a set of start objects, as queries or serialized objects, @@ -236,7 +250,7 @@ paths: operationId: listGoals tags: [correlate] requestBody: - description: search from start to goal classes + description: Search from start to goal classes. content: application/json: schema: @@ -308,19 +322,24 @@ paths: put: summary: Make console state available to an agent. description: > - Put the current state of the console so it can be retrieved by an agent via the MCP API. + Store console state so an agent can read it via MCP tool get_console. + The MCP client must have the same session (Authorization header) as the REST client. tags: [console] operationId: setConsole requestBody: description: Parameters for the updated console display. content: - text/json: + application/json: schema: $ref: "#/components/schemas/Console" required: true responses: "200": description: Console display updated successfully + content: + application/json: + schema: + type: object "400": description: invalid parameters content: @@ -333,48 +352,48 @@ paths: get: summary: SSE event stream of console display updates from an agent. description: > - Server-sent event (SSE) stream delivering console display updates. - Events are triggered by an agent using the MCP API to update the console. + Updates are triggered by update requests from MCP tool show_in_console. + The MCP client must have the same session (Authorization header) as the REST client. tags: [console] operationId: consoleEvents responses: "200": - description: Stream of console display updates. + description: > + SSE stream where each event's data field contains a JSON-encoded Console object. content: text/event-stream: - itemSchema: - # Reference a schema that defines the structure of each individual event + schema: $ref: "#/components/schemas/Console" components: schemas: + # NOTE: Schema descriptions are duplicated in x-oapi-codegen-extra-tags: jsonschema + # so that they are preserved in generated Go types for packages that use the jsonschema tag. Query: type: string pattern: "[^:]+:[^:]+:[^:]+" description: > - Represents a request to retrieve data for a particular Class. - It has 3 colon-separated parts: DOMAIN:CLASS:SELECTOR. + Query for data objects, format is DOMAIN:CLASS:SELECTOR. DOMAIN: name of a domain (e.g. k8s, log, metric, alert, trace, netflow). CLASS: name of a class in the domain (e.g. Pod, application, metric, alert, span, network). - SELECTOR: domain-specific query string, syntax varies by domain: - k8s uses JSON (e.g. {"namespace":"default","name":"my-pod"}), - log uses LogQL or JSON (e.g. {"namespace":"default"}), - metric uses PromQL (e.g. kube_pod_info{namespace="default"}), - alert uses JSON labels (e.g. {"alertname":"KubePodCrashLooping"}), - trace uses TraceQL (e.g. {resource.k8s.namespace.name="default"}), - netflow uses LogQL labels (e.g. {SrcK8S_Namespace="default"}). - example: "k8s:Pod:{namespace: foo, name: bar, labels: { a: b }, fields: { c: d }}" + SELECTOR: domain-specific query string. + x-go-type-skip-optional-pointer: true Class: type: string pattern: "[^:]+:[^:]+" description: > - Full name of a class of objects: DOMAIN:CLASS. + Full name of a class of data, format is DOMAIN:CLASS. DOMAIN: name of a domain (e.g. k8s, log, metric, alert, trace, netflow). CLASS: name within the domain. - Examples: k8s:Pod, k8s:Deployment.apps, log:application, metric:metric, alert:alert, trace:span, netflow:network. - example: "trace:span" + example: + - k8s:Pod + - k8s:Deployment.apps + - log:application + - metric:metric + - alert:alert + - netflow:network x-go-type-skip-optional-pointer: true Constraint: @@ -386,27 +405,20 @@ components: type: string description: Ignore objects with timestamps before this start time. format: date-time + x-oapi-codegen-extra-tags: + jsonschema: "Ignore objects with timestamps before this start time." end: type: string description: Ignore objects with timestamps after this end time. format: date-time example: "2017-07-21T17:32:28.1341231Z" + x-oapi-codegen-extra-tags: + jsonschema: "Ignore objects with timestamps after this end time." limit: type: integer description: Limit total number of objects per query. - timeout: - description: DEPRECATED store calls are cancelled with the request. - allOf: - - $ref: "#/components/schemas/Duration" - - Duration: - type: string - description: > - The duration string is a sequence of decimal numbers, each with optional - fraction and a unit suffix. Valid time units are: ns, us (or µs), ms, s, m, h. - format: duration - example: 2h45m - x-go-type: time.Duration + x-oapi-codegen-extra-tags: + jsonschema: "Limit total number of objects per query." Domain: type: object @@ -435,10 +447,14 @@ components: description: Class name of the start node. allOf: - $ref: "#/components/schemas/Class" + x-oapi-codegen-extra-tags: + jsonschema: "Class name of the start node, in DOMAIN:CLASS format." goal: description: Class name of the goal node. allOf: - $ref: "#/components/schemas/Class" + x-oapi-codegen-extra-tags: + jsonschema: "Class name of the goal node, in DOMAIN:CLASS format." rules: type: array x-go-type-skip-optional-pointer: true @@ -446,6 +462,8 @@ components: description: Set of rules followed along this edge. items: $ref: "#/components/schemas/Rule" + x-oapi-codegen-extra-tags: + jsonschema: "Set of rules followed along this edge." description: Directed edge in the result graph, from Start to Goal classes. Error: @@ -458,6 +476,9 @@ components: description: Error message. Goals: + description: > + Parameters for a goal-directed correlation search. + Finds paths from start objects to goal classes. type: object required: [goals, start] properties: @@ -465,23 +486,18 @@ components: type: array x-go-type-skip-optional-pointer: true description: > - Goal classes for correlation in "domain:class" format. - The search follows all paths from start to these classes. - Example: ["log:application"] or ["alert:alert", "metric:metric"]. - example: ["k8s:Pod"] + Goal classes in DOMAIN:CLASS format, e.g. log:application, alert:alert + example: ["k8s:Pod", "metric:metric"] items: $ref: "#/components/schemas/Class" x-oapi-codegen-extra-tags: - jsonschema: "Goal classes in domain:class format, e.g. log:application, alert:alert" + jsonschema: "Goal classes in DOMAIN:CLASS format, e.g. log:application, alert:alert." start: - description: "Starting point for the correlation search." + description: Starting point for the search. allOf: - $ref: "#/components/schemas/Start" x-oapi-codegen-extra-tags: - jsonschema: "Starting point for the correlation search" - description: > - Parameters for a goal-directed correlation search. - Finds paths from start objects to the specified goal classes. + jsonschema: "Starting point for the search." Graph: type: object @@ -492,15 +508,23 @@ components: x-go-type-skip-optional-pointer: true items: $ref: "#/components/schemas/Edge" + x-oapi-codegen-extra-tags: + jsonschema: "List of graph edges." nodes: description: List of graph nodes. type: array x-go-type-skip-optional-pointer: true items: $ref: "#/components/schemas/Node" + x-oapi-codegen-extra-tags: + jsonschema: "List of graph nodes." description: Graph resulting from a correlation search. Neighbors: + description: > + Parameters for a neighborhood correlation search. + Finds all objects reachable from the start by following correlation rules + up to the maximum depth. type: object required: [depth, start] properties: @@ -508,18 +532,15 @@ components: type: integer description: > Maximum number of correlation steps to follow from the start. - Depth 1 returns only direct correlations, depth 2 includes correlations of correlations, etc. + Depth 1 returns direct correlations only. x-oapi-codegen-extra-tags: - jsonschema: "Max correlation depth (1 = direct correlations only)" + jsonschema: "Maximum number of correlation steps to follow from the start. Depth 1 returns direct correlations only." start: - description: "Starting point for the correlation search." + description: Starting point for the search. allOf: - $ref: "#/components/schemas/Start" x-oapi-codegen-extra-tags: - jsonschema: "Starting point for the correlation search" - description: > - Parameters for a neighborhood correlation search. - Finds all correlated objects reachable within the specified depth from start objects. + jsonschema: "Starting point for the search." Node: description: Node in the result graph, contains results for a single class. @@ -528,22 +549,30 @@ components: properties: class: type: string - description: Full class name + description: Full class name. + 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. items: $ref: "#/components/schemas/QueryCount" + 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. + x-oapi-codegen-extra-tags: + jsonschema: "Number of results for this class, after de-duplication." result: description: Serialized result contents, may be large. type: array x-go-type-skip-optional-pointer: true items: $ref: "#/components/schemas/Object" + x-oapi-codegen-extra-tags: + jsonschema: "Serialized result contents, may be large." QueryCount: description: Query with number of results. @@ -553,10 +582,14 @@ components: count: description: Number of results, omitted if the query was not executed. type: integer + x-oapi-codegen-extra-tags: + jsonschema: "Number of results, omitted if the query was not executed." query: description: Query for correlation data. allOf: - $ref: "#/components/schemas/Query" + x-oapi-codegen-extra-tags: + jsonschema: "Query for correlation data in DOMAIN:CLASS:SELECTOR format." Rule: type: object @@ -565,12 +598,16 @@ components: name: type: string description: Name is an optional descriptive name. + x-oapi-codegen-extra-tags: + jsonschema: "Name is an optional descriptive name." queries: type: array x-go-type-skip-optional-pointer: true description: Queries generated while following this rule. items: $ref: "#/components/schemas/QueryCount" + x-oapi-codegen-extra-tags: + jsonschema: "Queries generated while following this rule." description: Rule is a correlation rule with a list of queries and results counts found during navigation. Object: @@ -580,24 +617,28 @@ components: x-go-type: json.RawMessage Start: + description: > + Starting point for a correlation search. + It usually specifies queries to get the starting objects, + but can include serialized objects as well as/instead of queries. type: object properties: class: description: > - Class of starting objects. Required when using 'objects' to specify the class of the serialized objects. - Optional with 'queries' since the class is embedded in each query string. + Class of starting objects. + Required when using 'objects' to provide serialized objects. + If queries are included, they must all be of this class. allOf: - $ref: "#/components/schemas/Class" x-go-type-skip-optional-pointer: true x-oapi-codegen-extra-tags: - jsonschema: "Optional class of start objects in domain:class format, e.g. k8s:Pod" + jsonschema: "Class of starting objects in DOMAIN:CLASS format. Required when using objects. If queries are included, they must all be of this class." constraint: + description: Constrains the objects that will be included in search results. allOf: - $ref: "#/components/schemas/Constraint" - description: > - Optional time and count constraints on the objects returned by queries. x-oapi-codegen-extra-tags: - jsonschema: "Optional time/count constraints on returned objects" + jsonschema: "Constrains the objects that will be included in search results." objects: description: > Start objects serialized as JSON. Requires 'class' to identify the object type. @@ -607,21 +648,18 @@ components: items: $ref: "#/components/schemas/Object" x-oapi-codegen-extra-tags: - jsonschema: "Start objects as JSON (requires class field). Alternative to queries." + jsonschema: "Start objects serialized as JSON. Requires class to identify the object type." queries: type: array x-go-type-skip-optional-pointer: true description: > Queries for starting objects in "domain:class:selector" format. This is the most common way to specify a starting point. - Example: ["k8s:Pod:{\"namespace\":\"default\",\"name\":\"my-pod\"}"] + example: ['k8s:Pod:{"namespace":"default","name":"my-pod"}'] items: $ref: "#/components/schemas/Query" x-oapi-codegen-extra-tags: - jsonschema: "Queries in domain:class:selector format, e.g. k8s:Pod:{namespace: default}" - description: > - Identifies the starting point for a correlation search. - Provide either 'queries' (most common) or 'class' + 'objects'. + jsonschema: "Queries for starting objects in DOMAIN:CLASS:SELECTOR format." Store: type: object @@ -632,41 +670,40 @@ components: Console: description: > State of the user's graphical console display (e.g. OpenShift web console). - The query field indicates what data the console is showing. - The search field optionally specifies a correlation search for the troubleshooting panel. type: object properties: - query: - description: "Query the console is displaying." + view: + description: The main console view displays the results of this query. allOf: - $ref: "#/components/schemas/Query" x-oapi-codegen-extra-tags: - jsonschema: "Query the console is displaying, in domain:class:selector format" + jsonschema: "Query for the main console view, in DOMAIN:CLASS:SELECTOR format." search: - description: "Optional correlation search for the troubleshooting panel." + description: The troubleshooting panel displays the results of this correlation search. allOf: - $ref: "#/components/schemas/Search" x-oapi-codegen-extra-tags: - jsonschema: "Optional correlation search for the troubleshooting panel" + jsonschema: "The troubleshooting panel displays the results of this correlation search." Search: description: > - Correlation search parameters for the console troubleshooting panel. - Set exactly one of 'goals' (targeted search to specific classes) or 'neighbors' (open-ended exploration to a depth). + Correlation search parameters. + Set exactly one of 'goals' (targeted search to specific classes) + or 'neighbors' (open-ended exploration to a depth). type: object properties: goals: - description: "Goal-directed search parameters (mutually exclusive with neighbors)." + description: Parameters for a goal-directed correlation search. allOf: - $ref: "#/components/schemas/Goals" x-oapi-codegen-extra-tags: - jsonschema: "Goal-directed search parameters (mutually exclusive with neighbors)" + jsonschema: "Parameters for a goal-directed correlation search." neighbors: - description: "Neighborhood search parameters (mutually exclusive with goals)." + description: Parameters for a neighborhood correlation search. allOf: - $ref: "#/components/schemas/Neighbors" x-oapi-codegen-extra-tags: - jsonschema: "Neighborhood search parameters (mutually exclusive with goals)" + jsonschema: "Parameters for a neighborhood correlation search." parameters: GraphOptions: @@ -677,13 +714,20 @@ components: explode: true schema: type: object + description: Options controlling the form of the returned graph. properties: rules: description: If true include rule names in graph edges. type: boolean + x-oapi-codegen-extra-tags: + jsonschema: "If true include rule names in graph edges." results: description: If true include full JSON results with each Query. type: boolean + x-oapi-codegen-extra-tags: + jsonschema: "If true include full JSON results with each Query." errors: - description: if true include non-fatal error messages. + description: If true include non-fatal error messages. type: boolean + x-oapi-codegen-extra-tags: + jsonschema: "If true include non-fatal error messages." diff --git a/web/src/__tests__/types.spec.ts b/web/src/__tests__/types.spec.ts index ba66bf2..9bdf8e9 100644 --- a/web/src/__tests__/types.spec.ts +++ b/web/src/__tests__/types.spec.ts @@ -52,7 +52,7 @@ describe('Constraint', () => { it.each([ { clientC: {}, typesC: {} }, { clientC: { start: start.toISOString(), end: end.toISOString() }, typesC: { start, end } }, - { clientC: { limit: 50, timeout: '1111111111' }, typesC: { limit: 50, timeoutNS: 1111111111 } }, + { clientC: { limit: 50 }, typesC: { limit: 50 } }, ] as Array<{ clientC: api.Constraint; typesC: Partial }>)( 'from/toAPI %s', ({ clientC, typesC }) => { diff --git a/web/src/korrel8r-client.ts b/web/src/korrel8r-client.ts index bf68fc5..a1e4fe7 100644 --- a/web/src/korrel8r-client.ts +++ b/web/src/korrel8r-client.ts @@ -1,15 +1,18 @@ +import { consoleFetch } from '@openshift-console/dynamic-plugin-sdk'; import { useQuery, UseQueryResult } from '@tanstack/react-query'; import { + listDomains as clientListDomains, + Console, Goals, Graph, - listDomains as clientListDomains, + graphGoals, + graphNeighbors, Neighbors, + consoleEvents as sdkConsoleEvents, + setConsole as sdkSetConsole, Start, - graphNeighbours, - graphGoals, } from './korrel8r/client'; import { createClient } from './korrel8r/client/client'; -import { consoleFetch } from '@openshift-console/dynamic-plugin-sdk'; import * as korrel8r from './korrel8r/types'; import { useTranslation } from 'react-i18next'; @@ -46,7 +49,7 @@ const getNeighborsGraph = (neighbours: Neighbors, signal: AbortSignal) => { throwOnError: true, }); - return graphNeighbours({ client: korrel8rClient, body: neighbours }); + return graphNeighbors({ client: korrel8rClient, body: neighbours }); }; const getGoalsGraph = (goals: Goals, signal: AbortSignal) => { @@ -142,3 +145,83 @@ const initRequest = async (req: Request): Promise => { } return init; }; + +interface CancellablePromise extends Promise { + cancel: () => void; +} + +export const setConsole = (body: Console): CancellablePromise => { + const controller = new AbortController(); + const korrel8rClient = createClient({ + baseUrl: KORREL8R_ENDPOINT, + fetch: requestWrapper, + signal: controller.signal, + throwOnError: true, + }); + const promise = sdkSetConsole({ client: korrel8rClient, body }).then(() => undefined); + (promise as CancellablePromise).cancel = () => controller.abort(); + return promise as CancellablePromise; +}; + +export const consoleEvents = ( + onEvent: (event: Console) => void | Promise, + onError: (err: unknown) => void, + onConnect: () => void, + { minDelay = 1000, maxDelay = 30000 }: { minDelay?: number; maxDelay?: number } = {}, +): (() => void) => { + const controller = new AbortController(); + + const run = async () => { + let delay = minDelay; + while (!controller.signal.aborted) { + try { + const korrel8rClient = createClient({ + baseUrl: KORREL8R_ENDPOINT, + fetch: requestWrapper, + signal: controller.signal, + }); + const result = await sdkConsoleEvents({ client: korrel8rClient }); + onConnect(); + delay = minDelay; + for await (const event of result.stream) { + if (controller.signal.aborted) break; + await onEvent(event as Console); + } + } catch (err) { + if (controller.signal.aborted) break; + onError(err); + await new Promise((r) => setTimeout(r, delay)); + delay = Math.min(delay * 2, maxDelay); + } + } + }; + run(); + + return () => controller.abort(); +}; + +export const retryWithBackoff = ( + operation: (signal: AbortSignal) => Promise, + onError: (err: any) => void, + { minDelay = 1000, maxDelay = 30000 }: { minDelay?: number; maxDelay?: number } = {}, +): (() => void) => { + const controller = new AbortController(); + + const run = async () => { + let delay = minDelay; + while (!controller.signal.aborted) { + try { + await operation(controller.signal); + return; + } catch (err) { + onError(err); // Set error even if canceled, we are still disconnected + if (controller.signal.aborted) break; + await new Promise((r) => setTimeout(r, delay)); + delay = Math.min(delay * 2, maxDelay); + } + } + }; + run(); + + return () => controller.abort(); +}; diff --git a/web/src/korrel8r/client/index.ts b/web/src/korrel8r/client/index.ts index c09bc1c..af98ba1 100644 --- a/web/src/korrel8r/client/index.ts +++ b/web/src/korrel8r/client/index.ts @@ -18,10 +18,10 @@ export type { ClientOptions, Console, ConsoleEventsData, + ConsoleEventsResponse, ConsoleEventsResponses, Constraint, Domain, - Duration, Edge, Error, Goals, @@ -70,10 +70,12 @@ export type { Rule, Search, SetConfigData, + SetConfigResponse, SetConfigResponses, SetConsoleData, SetConsoleError, SetConsoleErrors, + SetConsoleResponse, SetConsoleResponses, Start, Store, diff --git a/web/src/korrel8r/client/sdk.gen.ts b/web/src/korrel8r/client/sdk.gen.ts index 24eb84d..9a25cd4 100644 --- a/web/src/korrel8r/client/sdk.gen.ts +++ b/web/src/korrel8r/client/sdk.gen.ts @@ -4,6 +4,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; import type { ConsoleEventsData, + ConsoleEventsResponse, ConsoleEventsResponses, GraphGoalsData, GraphGoalsErrors, @@ -135,6 +136,8 @@ export const graphNeighbors = ( * * Specify a set of start objects, as queries or serialized objects, and a depth for the neighborhood search. Returns a graph of all paths with depth or less edges leading from start objects. * + * + * @deprecated */ export const graphNeighbours = ( options: Options, @@ -184,18 +187,17 @@ export const objects = ( /** * Make console state available to an agent. * - * Put the current state of the console so it can be retrieved by an agent via the MCP API. + * Store console state so an agent can read it via MCP tool get_console. The MCP client must have the same session (Authorization header) as the REST client. * */ export const setConsole = ( options: Options, ) => (options.client ?? client).put({ - bodySerializer: null, url: '/console', ...options, headers: { - 'Content-Type': 'text/json', + 'Content-Type': 'application/json', ...options.headers, }, }); @@ -203,11 +205,11 @@ export const setConsole = ( /** * SSE event stream of console display updates from an agent. * - * Server-sent event (SSE) stream delivering console display updates. Events are triggered by an agent using the MCP API to update the console. + * Updates are triggered by update requests from MCP tool show_in_console. The MCP client must have the same session (Authorization header) as the REST client. * */ export const consoleEvents = ( - options?: Options, + options?: Options, ) => (options?.client ?? client).sse.get({ url: '/console/events', diff --git a/web/src/korrel8r/client/types.gen.ts b/web/src/korrel8r/client/types.gen.ts index 47e78e3..c810752 100644 --- a/web/src/korrel8r/client/types.gen.ts +++ b/web/src/korrel8r/client/types.gen.ts @@ -5,13 +5,13 @@ export type ClientOptions = { }; /** - * Represents a request to retrieve data for a particular Class. It has 3 colon-separated parts: DOMAIN:CLASS:SELECTOR. DOMAIN: name of a domain (e.g. k8s, log, metric, alert, trace, netflow). CLASS: name of a class in the domain (e.g. Pod, application, metric, alert, span, network). SELECTOR: domain-specific query string, syntax varies by domain: k8s uses JSON (e.g. {"namespace":"default","name":"my-pod"}), log uses LogQL or JSON (e.g. {"namespace":"default"}), metric uses PromQL (e.g. kube_pod_info{namespace="default"}), alert uses JSON labels (e.g. {"alertname":"KubePodCrashLooping"}), trace uses TraceQL (e.g. {resource.k8s.namespace.name="default"}), netflow uses LogQL labels (e.g. {SrcK8S_Namespace="default"}). + * Query for data objects, format is DOMAIN:CLASS:SELECTOR. DOMAIN: name of a domain (e.g. k8s, log, metric, alert, trace, netflow). CLASS: name of a class in the domain (e.g. Pod, application, metric, alert, span, network). SELECTOR: domain-specific query string. * */ export type Query = string; /** - * Full name of a class of objects: DOMAIN:CLASS. DOMAIN: name of a domain (e.g. k8s, log, metric, alert, trace, netflow). CLASS: name within the domain. Examples: k8s:Pod, k8s:Deployment.apps, log:application, metric:metric, alert:alert, trace:span, netflow:network. + * Full name of a class of data, format is DOMAIN:CLASS. DOMAIN: name of a domain (e.g. k8s, log, metric, alert, trace, netflow). CLASS: name within the domain. * */ export type Class = string; @@ -32,18 +32,8 @@ export type Constraint = { * Limit total number of objects per query. */ limit?: number; - /** - * DEPRECATED store calls are cancelled with the request. - */ - timeout?: Duration; }; -/** - * The duration string is a sequence of decimal numbers, each with optional fraction and a unit suffix. Valid time units are: ns, us (or µs), ms, s, m, h. - * - */ -export type Duration = string; - /** * Domain configuration information. */ @@ -91,17 +81,17 @@ export type Error = { }; /** - * Parameters for a goal-directed correlation search. Finds paths from start objects to the specified goal classes. + * Parameters for a goal-directed correlation search. Finds paths from start objects to goal classes. * */ export type Goals = { /** - * Goal classes for correlation in "domain:class" format. The search follows all paths from start to these classes. Example: ["log:application"] or ["alert:alert", "metric:metric"]. + * Goal classes in DOMAIN:CLASS format, e.g. log:application, alert:alert * */ goals: Array; /** - * Starting point for the correlation search. + * Starting point for the search. */ start: Start; }; @@ -121,17 +111,17 @@ export type Graph = { }; /** - * Parameters for a neighborhood correlation search. Finds all correlated objects reachable within the specified depth from start objects. + * Parameters for a neighborhood correlation search. Finds all objects reachable from the start by following correlation rules up to the maximum depth. * */ export type Neighbors = { /** - * Maximum number of correlation steps to follow from the start. Depth 1 returns only direct correlations, depth 2 includes correlations of correlations, etc. + * Maximum number of correlation steps to follow from the start. Depth 1 returns direct correlations only. * */ depth: number; /** - * Starting point for the correlation search. + * Starting point for the search. */ start: Start; }; @@ -141,7 +131,7 @@ export type Neighbors = { */ export type Node = { /** - * Full class name + * Full class name. */ class: string; /** @@ -194,18 +184,17 @@ export type Object = { }; /** - * Identifies the starting point for a correlation search. Provide either 'queries' (most common) or 'class' + 'objects'. + * Starting point for a correlation search. It usually specifies queries to get the starting objects, but can include serialized objects as well as/instead of queries. * */ export type Start = { /** - * Class of starting objects. Required when using 'objects' to specify the class of the serialized objects. Optional with 'queries' since the class is embedded in each query string. + * Class of starting objects. Required when using 'objects' to provide serialized objects. If queries are included, they must all be of this class. * */ class?: Class; /** - * Optional time and count constraints on the objects returned by queries. - * + * Constrains the objects that will be included in search results. */ constraint?: Constraint; /** @@ -214,7 +203,7 @@ export type Start = { */ objects?: Array; /** - * Queries for starting objects in "domain:class:selector" format. This is the most common way to specify a starting point. Example: ["k8s:Pod:{\"namespace\":\"default\",\"name\":\"my-pod\"}"] + * Queries for starting objects in "domain:class:selector" format. This is the most common way to specify a starting point. * */ queries?: Array; @@ -228,31 +217,31 @@ export type Store = { }; /** - * State of the user's graphical console display (e.g. OpenShift web console). The query field indicates what data the console is showing. The search field optionally specifies a correlation search for the troubleshooting panel. + * State of the user's graphical console display (e.g. OpenShift web console). * */ export type Console = { /** - * Query the console is displaying. + * The main console view displays the results of this query. */ - query?: Query; + view?: Query; /** - * Optional correlation search for the troubleshooting panel. + * The troubleshooting panel displays the results of this correlation search. */ search?: Search; }; /** - * Correlation search parameters for the console troubleshooting panel. Set exactly one of 'goals' (targeted search to specific classes) or 'neighbors' (open-ended exploration to a depth). + * Correlation search parameters. Set exactly one of 'goals' (targeted search to specific classes) or 'neighbors' (open-ended exploration to a depth). * */ export type Search = { /** - * Goal-directed search parameters (mutually exclusive with neighbors). + * Parameters for a goal-directed correlation search. */ goals?: Goals; /** - * Neighborhood search parameters (mutually exclusive with goals). + * Parameters for a neighborhood correlation search. */ neighbors?: Neighbors; }; @@ -270,7 +259,7 @@ export type GraphOptions = { */ results?: boolean; /** - * if true include non-fatal error messages. + * If true include non-fatal error messages. */ errors?: boolean; }; @@ -291,9 +280,13 @@ export type SetConfigResponses = { /** * OK */ - 200: unknown; + 200: { + [key: string]: unknown; + }; }; +export type SetConfigResponse = SetConfigResponses[keyof SetConfigResponses]; + export type ListDomainsData = { body?: never; path?: never; @@ -378,7 +371,7 @@ export type GraphGoalsData = { */ results?: boolean; /** - * if true include non-fatal error messages. + * If true include non-fatal error messages. */ errors?: boolean; }; @@ -428,7 +421,7 @@ export type GraphNeighborsData = { */ results?: boolean; /** - * if true include non-fatal error messages. + * If true include non-fatal error messages. */ errors?: boolean; }; @@ -478,7 +471,7 @@ export type GraphNeighboursData = { */ results?: boolean; /** - * if true include non-fatal error messages. + * If true include non-fatal error messages. */ errors?: boolean; }; @@ -510,7 +503,7 @@ export type GraphNeighboursResponse = GraphNeighboursResponses[keyof GraphNeighb export type ListGoalsData = { /** - * search from start to goal classes + * Search from start to goal classes. */ body: Goals; path?: never; @@ -599,9 +592,13 @@ export type SetConsoleResponses = { /** * Console display updated successfully */ - 200: unknown; + 200: { + [key: string]: unknown; + }; }; +export type SetConsoleResponse = SetConsoleResponses[keyof SetConsoleResponses]; + export type ConsoleEventsData = { body?: never; path?: never; @@ -611,7 +608,10 @@ export type ConsoleEventsData = { export type ConsoleEventsResponses = { /** - * Stream of console display updates. + * SSE stream where each event's data field contains a JSON-encoded Console object. + * */ - 200: unknown; + 200: Console; }; + +export type ConsoleEventsResponse = ConsoleEventsResponses[keyof ConsoleEventsResponses]; diff --git a/web/src/korrel8r/types.ts b/web/src/korrel8r/types.ts index 6fac9c8..90a35fb 100644 --- a/web/src/korrel8r/types.ts +++ b/web/src/korrel8r/types.ts @@ -53,26 +53,21 @@ const parseDate = (s: string): Date | undefined => { return d?.valueOf() ? d : undefined; }; -// Parse a number, return undefined if invalid, rather than NaN. -const parseNumber = (s: string): number | undefined => (s && Number(s)) || undefined; - export class Constraint { public start?: Date; public end?: Date; public limit?: number; - /** NOTE timeout is in nanoseconds */ - public timeoutNS?: number; constructor(args: Partial = {}) { Object.assign(this, args); } - static fromAPI(ac: api.Constraint): Constraint { + static fromAPI(constraint: api.Constraint): Constraint | undefined { + if (!constraint) return undefined; return new Constraint({ - start: parseDate(ac?.start), - end: parseDate(ac?.end), - limit: ac?.limit || undefined, - timeoutNS: parseNumber(ac?.timeout), + start: parseDate(constraint.start), + end: parseDate(constraint.end), + limit: constraint?.limit || undefined, }); } @@ -81,14 +76,8 @@ export class Constraint { start: this?.start?.toISOString() || undefined, end: this?.end?.toISOString() || undefined, limit: this?.limit || undefined, - timeout: this?.timeoutNS?.toString() || undefined, }; } - - /** Return the timeout in seconds, with a fractional part */ - timeout(): number { - return this.timeoutNS / (1000 * 1000) || undefined; - } } // Domain converts between Korrel8r queries and URLs for a Korrel8r domain. From 832879f368cfdf674846c4896f50dbdbe71287c1 Mon Sep 17 00:00:00 2001 From: Alan Conway Date: Wed, 6 May 2026 17:09:46 -0400 Subject: [PATCH 2/2] feat: Console-agent link via Korrel8r Allows console to send state and receive requests to/from an AI agent. Console uses korrel8r REST API, agent uses MCP API. - Menu with AI icon allows enable/disable, shows connection status. - Subscribe to console updates from AI agent via korrel8r. - Always show troubleshooting panel, even if korrel8r is unavailable - it may become available. --- ..._troubleshooting-panel-console-plugin.json | 6 +- web/src/components/AIExperienceIcon.tsx | 15 +++ web/src/components/AgentMenu.tsx | 79 +++++++++++++ web/src/components/Korrel8rPanel.tsx | 32 +++--- web/src/hooks/useKorrel8r.ts | 108 +++++++++++++++--- web/src/hooks/useLocationQuery.ts | 19 +-- web/src/hooks/usePopover.tsx | 5 +- web/src/hooks/useTroubleshootingPanel.tsx | 8 +- web/src/redux-actions.ts | 35 ++++++ web/src/redux-reducers.ts | 8 ++ 10 files changed, 266 insertions(+), 49 deletions(-) create mode 100644 web/src/components/AIExperienceIcon.tsx create mode 100644 web/src/components/AgentMenu.tsx diff --git a/web/locales/en/plugin__troubleshooting-panel-console-plugin.json b/web/locales/en/plugin__troubleshooting-panel-console-plugin.json index 0d8ffbc..3bbb454 100644 --- a/web/locales/en/plugin__troubleshooting-panel-console-plugin.json +++ b/web/locales/en/plugin__troubleshooting-panel-console-plugin.json @@ -3,9 +3,13 @@ "<0>There are two types of correlation search:<1><0><0>Neighbourhood: Find all connected neighbours up to a maximum <2>depth (number of hops.)<1><0>Goal: Find paths to a selected <2>goal class. Finds all shortest paths, and some near-shortest paths.": "<0>There are two types of correlation search:<1><0><0>Neighbourhood: Find all connected neighbours up to a maximum <2>depth (number of hops.)<1><0>Goal: Find paths to a selected <2>goal class. Finds all shortest paths, and some near-shortest paths.", "Advanced Search": "Advanced Search", "Advanced search parameters": "Advanced search parameters", + "Agent Navigation": "Agent Navigation", + "AI Agent settings": "AI Agent settings", "Cancel": "Cancel", "Canceled": "Canceled", "Close": "Close", + "Connected": "Connected", + "Connecting...": "Connecting...", "Correlation graph already matches search": "Correlation graph already matches search", "Correlation graph is focused on the current view.": "Correlation graph is focused on the current view.", "Current view does not provide a starting point for correlation": "Current view does not provide a starting point for correlation", @@ -30,11 +34,11 @@ "No correlated data found": "No correlated data found", "No Correlated Signals Found": "No Correlated Signals Found", "No results.": "No results.", + "Off": "Off", "Open the Troubleshooting Panel": "Open the Troubleshooting Panel", "Other duration": "Other duration", "Quickly diagnose and resolve issues by exploring correlated observability signals for resources.": "Quickly diagnose and resolve issues by exploring correlated observability signals for resources.", "Refresh": "Refresh", - "Refresh the graph by re-running the current search.": "Refresh the graph by re-running the current search.", "Save": "Save", "Search": "Search", "Search Error": "Search Error", diff --git a/web/src/components/AIExperienceIcon.tsx b/web/src/components/AIExperienceIcon.tsx new file mode 100644 index 0000000..3ac4b1f --- /dev/null +++ b/web/src/components/AIExperienceIcon.tsx @@ -0,0 +1,15 @@ +import { SVGProps } from 'react'; + +// Created from http://redhat.brand-portal.adobe.com/aem/search.html, file name is rh-ui-icon-ai-experience. +export const AIExperienceIcon = (props: SVGProps) => ( + + + +); diff --git a/web/src/components/AgentMenu.tsx b/web/src/components/AgentMenu.tsx new file mode 100644 index 0000000..ba15e79 --- /dev/null +++ b/web/src/components/AgentMenu.tsx @@ -0,0 +1,79 @@ +import { + Dropdown, + DropdownItem, + DropdownList, + Flex, + FlexItem, + Icon, + Label, + MenuToggle, + MenuToggleElement, + Switch, +} from '@patternfly/react-core'; +import { Ref, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { setAgentEnabled } from '../redux-actions'; +import { State } from '../redux-reducers'; +import { AIExperienceIcon } from './AIExperienceIcon'; + +const AgentMenu = () => { + const { t } = useTranslation('plugin__troubleshooting-panel-console-plugin'); + const dispatch = useDispatch(); + const [isOpen, setIsOpen] = useState(false); + + const agentEnabled: boolean = useSelector((s: State) => s.plugins?.tp?.get('agentEnabled')); + const agentConnected: boolean = useSelector((s: State) => s.plugins?.tp?.get('agentConnected')); + + const statusLabel = agentEnabled + ? agentConnected + ? t('Connected') + : t('Connecting...') + : t('Off'); + + const statusColor = agentEnabled ? (agentConnected ? 'green' : 'orange') : undefined; + + return ( + ) => ( + setIsOpen(!isOpen)} + isExpanded={isOpen} + aria-label={t('AI Agent settings')} + > + + + + + )} + > + + + + + dispatch(setAgentEnabled(checked))} + /> + + + + + + + + + ); +}; + +export default AgentMenu; diff --git a/web/src/components/Korrel8rPanel.tsx b/web/src/components/Korrel8rPanel.tsx index 60e7b38..17d173b 100644 --- a/web/src/components/Korrel8rPanel.tsx +++ b/web/src/components/Korrel8rPanel.tsx @@ -20,21 +20,22 @@ import { SyncIcon, UnlinkIcon, } from '@patternfly/react-icons'; +import { useQueryClient } from '@tanstack/react-query'; +import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { useLocationQuery } from '../hooks/useLocationQuery'; import { usePluginAvailable } from '../hooks/usePluginAvailable'; +import { GraphResult, useKorrel8rGraph } from '../korrel8r-client'; import * as korrel8r from '../korrel8r/types'; import { defaultSearch, Search, setSearch } from '../redux-actions'; import { State } from '../redux-reducers'; import * as time from '../time'; import { AdvancedSearchForm } from './AdvancedSearchForm'; +import AgentMenu from './AgentMenu'; +import './korrel8rpanel.css'; import { TimeRangeDropdown } from './TimeRangeDropdown'; import { Korrel8rTopology } from './topology/Korrel8rTopology'; -import './korrel8rpanel.css'; -import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { GraphResult, useKorrel8rGraph } from '../korrel8r-client'; -import { useQueryClient } from '@tanstack/react-query'; export default function Korrel8rPanel() { const { t } = useTranslation('plugin__troubleshooting-panel-console-plugin'); @@ -139,6 +140,9 @@ export default function Korrel8rPanel() { {/* Right aligned buttons */} + {/* AI Agent menu */} + + {/* Advanced search toggle */} ) : ( - - - + )} diff --git a/web/src/hooks/useKorrel8r.ts b/web/src/hooks/useKorrel8r.ts index 6fa2563..2c6d65f 100644 --- a/web/src/hooks/useKorrel8r.ts +++ b/web/src/hooks/useKorrel8r.ts @@ -1,20 +1,90 @@ -import { useEffect, useState } from 'react'; -import { listDomains } from '../korrel8r-client'; - -export const useKorrel8r = () => { - const [isKorrel8rReachable, setIsKorrel8rReachable] = useState(false); - - useEffect(() => { - listDomains() - .then(() => { - setIsKorrel8rReachable(true); - }) - .catch(() => { - setIsKorrel8rReachable(false); - }); - }, []); - - return { - isKorrel8rReachable, - }; +import * as React from 'react'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; +import { consoleEvents, retryWithBackoff, setConsole } from '../korrel8r-client'; +import { Query } from '../korrel8r/types'; +import { + apiSearch, + openTP, + reduxSearch, + Search, + setAgentConnected, + setSearch, +} from '../redux-actions'; +import { State } from '../redux-reducers'; +import { useLocationQuery } from './useLocationQuery'; +import { useNavigateToQuery } from './useNavigateToQuery'; + +const logErr = (what: string, err: any) => { + if (err?.name !== 'AbortError') { + // eslint-disable-next-line no-console + console.error(`korrel8r console ${what}: ${err?.body?.error || err?.message || String(err)}`); + } }; + +const useKorrel8r = ({ + minDelay = 100, + maxDelay = 5000, +}: { minDelay?: number; maxDelay?: number } = {}) => { + const view = useLocationQuery()?.toString(); + const search: Search = useSelector((s: State) => s.plugins?.tp?.get('search'), shallowEqual); + const isOpen: boolean = useSelector((s: State) => s.plugins?.tp?.get('isOpen')); + const agentEnabled: boolean = useSelector((s: State) => s.plugins?.tp?.get('agentEnabled')); + const dispatch = useDispatch(); + const navigateToQuery = useNavigateToQuery(); + + // Send console update to korrel8r if there is one. + React.useEffect(() => { + if (!(agentEnabled && (view || (isOpen && search?.queryStr)))) { + return; + } + + const cleanup = retryWithBackoff( + async (signal) => { + const req = setConsole({ + view, + search: search?.queryStr ? apiSearch(search) : undefined, + }); + signal.addEventListener('abort', () => req.cancel(), { once: true }); + await req; + }, + (err: any) => logErr('send', err), + { minDelay, maxDelay }, + ); + + return () => cleanup(); + }, [view, search, isOpen, agentEnabled, minDelay, maxDelay]); + + const navigateToQueryRef = React.useRef(navigateToQuery); + React.useEffect(() => { + navigateToQueryRef.current = navigateToQuery; + }); + + // Subscribe to console update events from the agent. + React.useEffect(() => { + if (!agentEnabled) { + return; + } + return consoleEvents( + async (event) => { + if (event.view) { + navigateToQueryRef.current(Query.parse(event.view), null); + } + if (event.search) { + const search = reduxSearch(event.search); + if (search) { + dispatch(setSearch(search)); + dispatch(openTP()); + } + } + }, + (err: Error) => { + dispatch(setAgentConnected(false)); + logErr('disconnected', err); + }, + () => dispatch(setAgentConnected(true)), + { minDelay, maxDelay }, + ); + }, [agentEnabled, minDelay, maxDelay, dispatch]); +}; + +export default useKorrel8r; diff --git a/web/src/hooks/useLocationQuery.ts b/web/src/hooks/useLocationQuery.ts index 2b0b8f6..97a5a61 100644 --- a/web/src/hooks/useLocationQuery.ts +++ b/web/src/hooks/useLocationQuery.ts @@ -39,16 +39,21 @@ const useBrowserLocation = () => { return location; }; -/** Returns the Korrel8r query for the current browser location or undefined */ +/** Returns the Korrel8r query for the current browser location or undefined. */ export const useLocationQuery = (): Query | undefined => { const domains = useDomains(); const location = useBrowserLocation(); + const [lastError, setLastError] = useState(''); try { - const link = new URIRef(location.pathname + location.search); - const q = domains.linkToQuery(link); - return q; - } catch (e) { - // eslint-disable-next-line no-console - console.warn(`korrel8r useLocationQuery: ${e}`); + const query = domains.linkToQuery(new URIRef(location.pathname + location.search)); + if (lastError) setLastError(''); + return query; + } catch (err) { + const errStr = String(err); + if (errStr !== lastError) { + // eslint-disable-next-line no-console + console.warn(`korrel8r useLocationQuery: ${errStr}`); + setLastError(errStr); + } } }; diff --git a/web/src/hooks/usePopover.tsx b/web/src/hooks/usePopover.tsx index 9d73855..ecab270 100644 --- a/web/src/hooks/usePopover.tsx +++ b/web/src/hooks/usePopover.tsx @@ -1,14 +1,15 @@ import { useOverlay } from '@openshift-console/dynamic-plugin-sdk'; - +import { useEffect } from 'react'; import { useSelector } from 'react-redux'; import Popover from '../components/Popover'; import { State } from '../redux-reducers'; -import { useEffect } from 'react'; +import useKorrel8r from './useKorrel8r'; const usePopover = () => { const isOpen = useSelector((state: State) => state.plugins?.tp?.get('isOpen')); const launchModal = useOverlay(); + useKorrel8r(); useEffect(() => { if (launchModal && isOpen) { diff --git a/web/src/hooks/useTroubleshootingPanel.tsx b/web/src/hooks/useTroubleshootingPanel.tsx index 399b25b..c29fbfa 100644 --- a/web/src/hooks/useTroubleshootingPanel.tsx +++ b/web/src/hooks/useTroubleshootingPanel.tsx @@ -1,13 +1,11 @@ import { Action, ExtensionHook, useActivePerspective } from '@openshift-console/dynamic-plugin-sdk'; import { InfrastructureIcon } from '@patternfly/react-icons'; +import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { openTP } from '../redux-actions'; -import { useKorrel8r } from './useKorrel8r'; -import { useCallback, useEffect, useState } from 'react'; const useTroubleshootingPanel: ExtensionHook> = () => { - const { isKorrel8rReachable } = useKorrel8r(); const { t } = useTranslation('plugin__troubleshooting-panel-console-plugin'); const [perspective] = useActivePerspective(); const dispatch = useDispatch(); @@ -16,7 +14,7 @@ const useTroubleshootingPanel: ExtensionHook> = () => { }, [dispatch]); const getActions = useCallback(() => { - if (!isKorrel8rReachable || perspective === 'dev') { + if (perspective === 'dev') { return []; } const actions = [ @@ -34,7 +32,7 @@ const useTroubleshootingPanel: ExtensionHook> = () => { }, ]; return actions; - }, [open, t, isKorrel8rReachable, perspective]); + }, [open, t, perspective]); const [actions, setActions] = useState>(getActions()); diff --git a/web/src/redux-actions.ts b/web/src/redux-actions.ts index e062f44..9df4ce3 100644 --- a/web/src/redux-actions.ts +++ b/web/src/redux-actions.ts @@ -1,10 +1,13 @@ import { action, ActionType as Action } from 'typesafe-actions'; +import * as api from './korrel8r/client'; import { Duration, HOUR, Period } from './time'; export enum ActionType { CloseTroubleshootingPanel = 'closeTroubleshootingPanel', OpenTroubleshootingPanel = 'openTroubleshootingPanel', SetSearch = 'setSearch', + SetAgentConnected = 'setAgentConnected', + SetAgentEnabled = 'setAgentEnabled', } export enum SearchType { @@ -32,11 +35,43 @@ export const defaultSearch: Search = { export const closeTP = () => action(ActionType.CloseTroubleshootingPanel); export const openTP = () => action(ActionType.OpenTroubleshootingPanel); export const setSearch = (search: Search) => action(ActionType.SetSearch, search); +export const setAgentConnected = (connected: boolean) => + action(ActionType.SetAgentConnected, connected); +export const setAgentEnabled = (enabled: boolean) => action(ActionType.SetAgentEnabled, enabled); export const actions = { closeTP, openTP, setSearch, + setAgentConnected, + setAgentEnabled, }; export type TPAction = Action; + +export const apiSearch = (search: Search): api.Search => { + const start: api.Start = { queries: [search.queryStr] }; + if (search.searchType === SearchType.Goal) { + return { goals: { start, goals: [search.goal] } }; + } + return { neighbors: { start, depth: search.depth } }; +}; + +export const reduxSearch = (search: api.Search): Search | undefined => { + let result: Search | undefined = undefined; + if (search.goals && !search.neighbors) { + result = { + queryStr: search.goals.start?.queries?.[0], + searchType: SearchType.Goal, + goal: search.goals.goals?.[0], + }; + } + if (search.neighbors && !search.goals) { + result = { + queryStr: search.neighbors.start?.queries?.[0], + searchType: SearchType.Depth, + depth: search.neighbors.depth, + }; + } + return result?.queryStr ? result : undefined; +}; diff --git a/web/src/redux-reducers.ts b/web/src/redux-reducers.ts index 4b34245..361c0ba 100644 --- a/web/src/redux-reducers.ts +++ b/web/src/redux-reducers.ts @@ -18,6 +18,8 @@ const reducer = (state: TPState, action: TPAction): TPState => { return ImmutableMap({ isOpen: false, search: defaultSearch, + agentEnabled: false, + agentConnected: false, }); } @@ -31,6 +33,12 @@ const reducer = (state: TPState, action: TPAction): TPState => { case ActionType.SetSearch: return state.set('search', action.payload); + case ActionType.SetAgentConnected: + return state.set('agentConnected', action.payload); + + case ActionType.SetAgentEnabled: + return state.set('agentEnabled', action.payload); + default: break; }