mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-15 21:20:28 +01:00
Compare commits
6 Commits
nv/schema-
...
fix/ai-ass
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74d5d0fd38 | ||
|
|
a9c13f41de | ||
|
|
9372eb955d | ||
|
|
4392364fd3 | ||
|
|
cc6605b64e | ||
|
|
94b475794e |
@@ -159,6 +159,25 @@ export interface CancelResponseDTO {
|
||||
state: ExecutionStateDTO;
|
||||
}
|
||||
|
||||
export interface ChipDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @description Stable chip id. Rule-engine chips use intent ids.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ChipsResponseDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
chips: ChipDTO[];
|
||||
}
|
||||
|
||||
export type ClarificationFieldDTOOptions = string[] | null;
|
||||
|
||||
export type ClarificationFieldDTODefault = string | string[] | null;
|
||||
@@ -386,15 +405,74 @@ export type ErrorBodyDTOErrors = ErrorResponseAdditionalDTO[] | null;
|
||||
|
||||
export type ErrorBodyDTOUrl = string | null;
|
||||
|
||||
/**
|
||||
* Machine-readable error codes carried on ``ErrorBody.code``.
|
||||
|
||||
**Extensible set.** This enum is the single source of truth for every code
|
||||
the backend can emit, on both the REST envelope and the SSE ``ErrorEvent``.
|
||||
It is published in the OpenAPI schema (and therefore the generated TS
|
||||
client) so clients get autocomplete and a typed discriminant. The set is
|
||||
expected to *grow*: adding a member is a backward-compatible change (the
|
||||
wire is still a plain JSON string), so clients MUST treat unknown codes
|
||||
gracefully — branch on the codes they handle and keep a default fallback,
|
||||
never hard-reject an unrecognized value. Re-exported from ``app.errors``
|
||||
for convenience; ``AssistantError(code=...)`` requires a member of this
|
||||
enum so a typo can never reach a client.
|
||||
*/
|
||||
export enum ErrorCodeDTO {
|
||||
missing_signoz_url = 'missing_signoz_url',
|
||||
invalid_signoz_url = 'invalid_signoz_url',
|
||||
invalid_content_length = 'invalid_content_length',
|
||||
invalid_fork_target = 'invalid_fork_target',
|
||||
rate_limit_override_exceeds_ceiling = 'rate_limit_override_exceeds_ceiling',
|
||||
thread_message_limit = 'thread_message_limit',
|
||||
validation_error = 'validation_error',
|
||||
missing_token = 'missing_token',
|
||||
invalid_token = 'invalid_token',
|
||||
permission_denied = 'permission_denied',
|
||||
user_disabled = 'user_disabled',
|
||||
org_disabled = 'org_disabled',
|
||||
thread_not_found = 'thread_not_found',
|
||||
message_not_found = 'message_not_found',
|
||||
execution_not_found = 'execution_not_found',
|
||||
approval_not_found = 'approval_not_found',
|
||||
clarification_not_found = 'clarification_not_found',
|
||||
action_metadata_not_found = 'action_metadata_not_found',
|
||||
user_not_found = 'user_not_found',
|
||||
region_not_configured = 'region_not_configured',
|
||||
thread_busy = 'thread_busy',
|
||||
thread_has_active_execution = 'thread_has_active_execution',
|
||||
no_active_execution = 'no_active_execution',
|
||||
approval_superseded = 'approval_superseded',
|
||||
clarification_superseded = 'clarification_superseded',
|
||||
undo_conflict = 'undo_conflict',
|
||||
revert_conflict = 'revert_conflict',
|
||||
revert_expired = 'revert_expired',
|
||||
restore_expired = 'restore_expired',
|
||||
connection_limit_exceeded = 'connection_limit_exceeded',
|
||||
hourly_message_limit = 'hourly_message_limit',
|
||||
daily_message_limit = 'daily_message_limit',
|
||||
daily_token_limit = 'daily_token_limit',
|
||||
daily_cost_limit = 'daily_cost_limit',
|
||||
upstream_auth_error = 'upstream_auth_error',
|
||||
max_turns_exceeded = 'max_turns_exceeded',
|
||||
budget_exceeded = 'budget_exceeded',
|
||||
agent_execution_error = 'agent_execution_error',
|
||||
cli_not_found = 'cli_not_found',
|
||||
cli_connection_error = 'cli_connection_error',
|
||||
cli_process_error = 'cli_process_error',
|
||||
sandbox_unavailable = 'sandbox_unavailable',
|
||||
mcp_unavailable = 'mcp_unavailable',
|
||||
internal_error = 'internal_error',
|
||||
region_unreachable = 'region_unreachable',
|
||||
heartbeat_expired = 'heartbeat_expired',
|
||||
replay_unavailable = 'replay_unavailable',
|
||||
}
|
||||
/**
|
||||
* Inner error object — matches Go ErrorsJSON.
|
||||
*/
|
||||
export interface ErrorBodyDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @pattern ^[a-z_]+$
|
||||
*/
|
||||
code: string;
|
||||
code: ErrorCodeDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -490,6 +568,23 @@ export type MessageActionDTOQuery = MessageActionDTOQueryAnyOf | null;
|
||||
|
||||
export type MessageActionDTOUrl = string | null;
|
||||
|
||||
/**
|
||||
* Explorer namespace a saved view belongs to — its ``sourcePage``.
|
||||
|
||||
Mirrors the SigNoz product's saved-view ``sourcePage`` values so the
|
||||
frontend can route an ``open_resource`` action for a view to the right
|
||||
Explorer via its existing ``SOURCEPAGE_VS_ROUTES`` map. ``meter`` is the
|
||||
Cost Meter Explorer and is intentionally distinct from ``metrics`` (the
|
||||
product persists and lists meter views under ``sourcePage="meter"``).
|
||||
*/
|
||||
export enum SavedViewEntityDTO {
|
||||
logs = 'logs',
|
||||
traces = 'traces',
|
||||
metrics = 'metrics',
|
||||
meter = 'meter',
|
||||
}
|
||||
export type MessageActionDTOEntity = SavedViewEntityDTO | null;
|
||||
|
||||
export enum MessageActionKindDTO {
|
||||
undo = 'undo',
|
||||
revert = 'revert',
|
||||
@@ -500,7 +595,7 @@ export enum MessageActionKindDTO {
|
||||
apply_filter = 'apply_filter',
|
||||
}
|
||||
/**
|
||||
* Assistant action. Kind-specific requirements: rollback actions require actionMetadataId/resourceType/resourceId; follow_up requires input.intent; open_resource requires resourceType/resourceId; apply_filter requires signal and query; open_docs requires a SigNoz docs url.
|
||||
* Assistant action. Kind-specific requirements: rollback actions require actionMetadataId/resourceType/resourceId; follow_up requires input.intent; open_resource requires resourceType/resourceId; apply_filter requires signal and query; open_docs requires a SigNoz docs url. open_resource for a saved view also carries entity (logs/traces/metrics/meter) so the frontend routes to the correct Explorer.
|
||||
*/
|
||||
export interface MessageActionDTO {
|
||||
kind: MessageActionKindDTO;
|
||||
@@ -517,6 +612,7 @@ export interface MessageActionDTO {
|
||||
signal?: MessageActionDTOSignal;
|
||||
query?: MessageActionDTOQuery;
|
||||
url?: MessageActionDTOUrl;
|
||||
entity?: MessageActionDTOEntity;
|
||||
}
|
||||
|
||||
export enum MessageContentTypeDTO {
|
||||
@@ -590,6 +686,26 @@ export interface MessageSummaryDTO {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export enum PageTypeDTO {
|
||||
homepage = 'homepage',
|
||||
dashboard_detail = 'dashboard_detail',
|
||||
dashboard_list = 'dashboard_list',
|
||||
panel_edit = 'panel_edit',
|
||||
panel_fullscreen = 'panel_fullscreen',
|
||||
logs_explorer = 'logs_explorer',
|
||||
log_detail = 'log_detail',
|
||||
traces_explorer = 'traces_explorer',
|
||||
trace_detail = 'trace_detail',
|
||||
metrics_explorer = 'metrics_explorer',
|
||||
service_detail = 'service_detail',
|
||||
services_list = 'services_list',
|
||||
alert_edit = 'alert_edit',
|
||||
alert_list = 'alert_list',
|
||||
alert_new = 'alert_new',
|
||||
alerts_triggered = 'alerts_triggered',
|
||||
infra_entity_detail = 'infra_entity_detail',
|
||||
other = 'other',
|
||||
}
|
||||
export enum ReadinessChecksDTODatabase {
|
||||
ok = 'ok',
|
||||
failed = 'failed',
|
||||
@@ -990,8 +1106,10 @@ export type MessageActionEventDTOQuery = MessageActionEventDTOQueryAnyOf | null;
|
||||
|
||||
export type MessageActionEventDTOUrl = string | null;
|
||||
|
||||
export type MessageActionEventDTOEntity = SavedViewEntityDTO | null;
|
||||
|
||||
/**
|
||||
* Assistant action. Kind-specific requirements: rollback actions require actionMetadataId/resourceType/resourceId; follow_up requires input.intent; open_resource requires resourceType/resourceId; apply_filter requires signal and query; open_docs requires a SigNoz docs url.
|
||||
* Assistant action. Kind-specific requirements: rollback actions require actionMetadataId/resourceType/resourceId; follow_up requires input.intent; open_resource requires resourceType/resourceId; apply_filter requires signal and query; open_docs requires a SigNoz docs url. open_resource for a saved view also carries entity (logs/traces/metrics/meter) so the frontend routes to the correct Explorer.
|
||||
*/
|
||||
export interface MessageActionEventDTO {
|
||||
kind: MessageActionKindDTO;
|
||||
@@ -1008,6 +1126,7 @@ export interface MessageActionEventDTO {
|
||||
signal?: MessageActionEventDTOSignal;
|
||||
query?: MessageActionEventDTOQuery;
|
||||
url?: MessageActionEventDTOUrl;
|
||||
entity?: MessageActionEventDTOEntity;
|
||||
}
|
||||
|
||||
export type MessageEventDTOActions = MessageActionEventDTO[] | null;
|
||||
@@ -1385,3 +1504,21 @@ export type GetUsageApiV1AssistantUsageGetHeaders = {
|
||||
*/
|
||||
'X-SigNoz-URL'?: string | null;
|
||||
};
|
||||
|
||||
export type GetChipsApiV1AssistantEmptyStateChipsGetParams = {
|
||||
/**
|
||||
* @description Frontend-declared page type. Typed as an enum, but unrecognized values are coerced to 'other' (not rejected) so a new frontend page type works before the backend knows it. The page type alone identifies the focused entity (e.g. trace_detail) for the 'Explain this …' chip; the agent reads the concrete entity from page context once a chip is clicked, so no separate entity id is needed.
|
||||
*/
|
||||
page_type: PageTypeDTO;
|
||||
};
|
||||
|
||||
export type GetChipsApiV1AssistantEmptyStateChipsGetHeaders = {
|
||||
/**
|
||||
* @description SigNoz auth token (Bearer or raw JWT)
|
||||
*/
|
||||
authorization?: string | null;
|
||||
/**
|
||||
* @description SigNoz instance base URL for multi-tenant deployments. Falls back to SIGNOZ_API_URL env var when omitted.
|
||||
*/
|
||||
'X-SigNoz-URL'?: string | null;
|
||||
};
|
||||
|
||||
24
frontend/src/api/saveView/getViewById.ts
Normal file
24
frontend/src/api/saveView/getViewById.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import axios from 'api';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { ViewProps } from 'types/api/saveViews/types';
|
||||
|
||||
/**
|
||||
* Fetches a single saved view by ID (`GET /api/v1/explorer/views/{viewId}`).
|
||||
*
|
||||
* Hand-maintained alongside the other `api/saveView/*` clients — explorer views
|
||||
* are not in `docs/api/openapi.yml`, so Orval does not generate a hook here
|
||||
* (unlike e.g. `useGetChannelByID` under `api/generated/services/channels`).
|
||||
*
|
||||
* Used by the AI assistant "Open view" action to load `compositeQuery` and
|
||||
* navigate to the correct explorer without listing every view per source page.
|
||||
* See `container/AIAssistant/components/ActionsSection/utils/openSavedView.ts`.
|
||||
*/
|
||||
export interface GetViewByIdProps {
|
||||
status: string;
|
||||
data: ViewProps;
|
||||
}
|
||||
|
||||
export const getViewById = (
|
||||
viewKey: string,
|
||||
): Promise<AxiosResponse<GetViewByIdProps>> =>
|
||||
axios.get(`/explorer/views/${viewKey}`);
|
||||
@@ -0,0 +1,89 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
|
||||
import { getAutoContexts } from '../getAutoContexts';
|
||||
|
||||
describe('getAutoContexts', () => {
|
||||
it('returns alert detail context on alert overview with ruleId', () => {
|
||||
const ruleId = 'rule-abc';
|
||||
const search = `?${QueryParams.ruleId}=${ruleId}&${QueryParams.relativeTime}=1h`;
|
||||
|
||||
const contexts = getAutoContexts(ROUTES.ALERT_OVERVIEW, search);
|
||||
|
||||
expect(contexts).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: ruleId,
|
||||
metadata: {
|
||||
page: 'alert_detail',
|
||||
ruleId,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns alert detail context on alert history with ruleId', () => {
|
||||
const ruleId = 'rule-xyz';
|
||||
const startTime = '1700000000000';
|
||||
const endTime = '1700003600000';
|
||||
const search = `?${QueryParams.ruleId}=${ruleId}&${QueryParams.startTime}=${startTime}&${QueryParams.endTime}=${endTime}`;
|
||||
|
||||
const contexts = getAutoContexts(ROUTES.ALERT_HISTORY, search);
|
||||
|
||||
expect(contexts).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: ruleId,
|
||||
metadata: {
|
||||
page: 'alert_detail',
|
||||
ruleId,
|
||||
timeRange: {
|
||||
start: Number(startTime),
|
||||
end: Number(endTime),
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns triggered alerts context on alert history without ruleId', () => {
|
||||
const contexts = getAutoContexts(ROUTES.ALERT_HISTORY, '');
|
||||
|
||||
expect(contexts).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: null,
|
||||
metadata: {
|
||||
page: 'alerts_triggered',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns dashboard detail context on dashboard page', () => {
|
||||
const dashboardId = 'dash-123';
|
||||
const pathname = ROUTES.DASHBOARD.replace(':dashboardId', dashboardId);
|
||||
|
||||
const contexts = getAutoContexts(pathname, '');
|
||||
|
||||
expect(contexts).toStrictEqual([
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'dashboard',
|
||||
resourceId: dashboardId,
|
||||
metadata: {
|
||||
page: 'dashboard_detail',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array on alert overview without ruleId', () => {
|
||||
const contexts = getAutoContexts(ROUTES.ALERT_OVERVIEW, '');
|
||||
|
||||
expect(contexts).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,15 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
|
||||
import { openSavedViewByKey } from './utils/openSavedView';
|
||||
import {
|
||||
isSavedViewOpenAction,
|
||||
resolveOpenResourceType,
|
||||
resolveResourceId,
|
||||
resolveSavedViewSourceHint,
|
||||
} from './utils/resolveOpenResource';
|
||||
import { ResourceType, resourceRoute } from './utils/resourceRoute';
|
||||
|
||||
import styles from './ActionsSection.module.scss';
|
||||
|
||||
interface ActionsSectionProps {
|
||||
@@ -55,20 +64,6 @@ interface ActionsSectionProps {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource-type strings the backend uses for `open_resource` and rollback
|
||||
* actions. Centralized here so the route/module lookups below stay in sync.
|
||||
*/
|
||||
const ResourceType = {
|
||||
dashboard: 'dashboard',
|
||||
alert: 'alert',
|
||||
service: 'service',
|
||||
saved_view: 'saved_view',
|
||||
logs_explorer: 'logs_explorer',
|
||||
traces_explorer: 'traces_explorer',
|
||||
metrics_explorer: 'metrics_explorer',
|
||||
} as const;
|
||||
|
||||
/** Maps an open_resource action's resourceType to its product module name. */
|
||||
function targetModuleForResource(resourceType: string): string | null {
|
||||
switch (resourceType) {
|
||||
@@ -78,6 +73,8 @@ function targetModuleForResource(resourceType: string): string | null {
|
||||
return 'alerts';
|
||||
case ResourceType.service:
|
||||
return 'apm';
|
||||
case ResourceType.channel:
|
||||
return 'channels';
|
||||
case ResourceType.saved_view:
|
||||
return 'savedViews';
|
||||
case ResourceType.logs_explorer:
|
||||
@@ -140,39 +137,6 @@ function ActionIcon({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an `open_resource` action to an in-app route.
|
||||
* Resource taxonomy mirrors `MessageContextDTOType`: dashboard, alert,
|
||||
* saved_view, service, and the *_explorer signals.
|
||||
*/
|
||||
function resourceRoute(
|
||||
resourceType: string,
|
||||
resourceId: string,
|
||||
): string | null {
|
||||
switch (resourceType) {
|
||||
case ResourceType.dashboard:
|
||||
return ROUTES.DASHBOARD.replace(':dashboardId', resourceId);
|
||||
case ResourceType.alert: {
|
||||
const params = new URLSearchParams({ [QueryParams.ruleId]: resourceId });
|
||||
return `${ROUTES.EDIT_ALERTS}?${params.toString()}`;
|
||||
}
|
||||
case ResourceType.service:
|
||||
return ROUTES.SERVICE_METRICS.replace(':servicename', resourceId);
|
||||
case ResourceType.saved_view:
|
||||
// No detail route — saved views land on the list page.
|
||||
// Caller may provide signal-aware metadata in future; default to logs.
|
||||
return ROUTES.LOGS_SAVE_VIEWS;
|
||||
case ResourceType.logs_explorer:
|
||||
return ROUTES.LOGS_EXPLORER;
|
||||
case ResourceType.traces_explorer:
|
||||
return ROUTES.TRACES_EXPLORER;
|
||||
case ResourceType.metrics_explorer:
|
||||
return ROUTES.METRICS_EXPLORER_EXPLORER;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent emits `action.query` as the SigNoz REST query-range request body:
|
||||
*
|
||||
@@ -484,6 +448,35 @@ export default function ActionsSection({
|
||||
setResults((prev) => ({ ...prev, [key]: result }));
|
||||
};
|
||||
|
||||
const runOpenSavedView = async (
|
||||
key: string,
|
||||
action: MessageActionDTO,
|
||||
): Promise<void> => {
|
||||
const resourceId = resolveResourceId(action);
|
||||
if (!resourceId) {
|
||||
return;
|
||||
}
|
||||
setResult(key, { state: 'loading' });
|
||||
try {
|
||||
await openSavedViewByKey(
|
||||
resourceId,
|
||||
resolveSavedViewSourceHint(action),
|
||||
history,
|
||||
);
|
||||
void logEvent(AIAssistantEvents.ResourceOpened, {
|
||||
threadId,
|
||||
messageId,
|
||||
targetModule: targetModuleForResource(ResourceType.saved_view),
|
||||
resourceId,
|
||||
});
|
||||
setResult(key, { state: 'success' });
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Failed to open saved view';
|
||||
setResult(key, { state: 'error', error: message });
|
||||
}
|
||||
};
|
||||
|
||||
const runRollback = async (
|
||||
key: string,
|
||||
action: MessageActionDTO,
|
||||
@@ -502,6 +495,31 @@ export default function ActionsSection({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenResource = (key: string, action: MessageActionDTO): void => {
|
||||
if (isSavedViewOpenAction(action)) {
|
||||
void runOpenSavedView(key, action);
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceType = resolveOpenResourceType(action);
|
||||
const resourceId = resolveResourceId(action);
|
||||
if (!resourceType || !resourceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = resourceRoute(resourceType, resourceId);
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
void logEvent(AIAssistantEvents.ResourceOpened, {
|
||||
threadId,
|
||||
messageId,
|
||||
targetModule: targetModuleForResource(resourceType),
|
||||
resourceId,
|
||||
});
|
||||
history.push(path);
|
||||
};
|
||||
|
||||
const handleClick = (key: string, action: MessageActionDTO): void => {
|
||||
switch (action.kind) {
|
||||
case MessageActionKindDTO.open_docs: {
|
||||
@@ -542,21 +560,9 @@ export default function ActionsSection({
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MessageActionKindDTO.open_resource: {
|
||||
if (action.resourceType && action.resourceId) {
|
||||
const path = resourceRoute(action.resourceType, action.resourceId);
|
||||
if (path) {
|
||||
void logEvent(AIAssistantEvents.ResourceOpened, {
|
||||
threadId,
|
||||
messageId,
|
||||
targetModule: targetModuleForResource(action.resourceType),
|
||||
resourceId: action.resourceId,
|
||||
});
|
||||
history.push(path);
|
||||
}
|
||||
}
|
||||
case MessageActionKindDTO.open_resource:
|
||||
handleOpenResource(key, action);
|
||||
break;
|
||||
}
|
||||
case MessageActionKindDTO.undo:
|
||||
case MessageActionKindDTO.revert:
|
||||
case MessageActionKindDTO.restore: {
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import {
|
||||
ApplyFilterSignalDTO,
|
||||
MessageActionKindDTO,
|
||||
SavedViewEntityDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { getAllViews } from 'api/saveView/getAllViews';
|
||||
import { getViewById } from 'api/saveView/getViewById';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
|
||||
import { AllViewsProps, ViewProps } from 'types/api/saveViews/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import type { History } from 'history';
|
||||
|
||||
import {
|
||||
buildExplorerNavigationUrl,
|
||||
findSavedViewInLists,
|
||||
openSavedView,
|
||||
openSavedViewByKey,
|
||||
} from '../openSavedView';
|
||||
import {
|
||||
entityToDataSource,
|
||||
isSavedViewOpenAction,
|
||||
resolveActionEntity,
|
||||
resolveOpenResourceType,
|
||||
resolveResourceId,
|
||||
resolveResourceType,
|
||||
resolveSavedViewSourceHint,
|
||||
} from '../resolveOpenResource';
|
||||
import { resourceRoute, ResourceType } from '../resourceRoute';
|
||||
|
||||
jest.mock('api/saveView/getAllViews');
|
||||
jest.mock('api/saveView/getViewById');
|
||||
|
||||
jest.mock(
|
||||
'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi',
|
||||
() => ({
|
||||
mapQueryDataFromApi: jest.fn(() => ({
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [{ id: 'A' }],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
const mockedGetAllViews = getAllViews as jest.MockedFunction<
|
||||
typeof getAllViews
|
||||
>;
|
||||
const mockedGetViewById = getViewById as jest.MockedFunction<
|
||||
typeof getViewById
|
||||
>;
|
||||
|
||||
function makeView(id: string, sourcePage: DataSource): ViewProps {
|
||||
return {
|
||||
id,
|
||||
name: `View ${id}`,
|
||||
category: 'test',
|
||||
createdAt: '2021-07-07T06:31:00.000Z',
|
||||
createdBy: 'user',
|
||||
updatedAt: '2021-07-07T06:33:00.000Z',
|
||||
updatedBy: 'user',
|
||||
sourcePage,
|
||||
tags: [],
|
||||
extraData: '',
|
||||
compositeQuery: {
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
} as ICompositeMetricQuery,
|
||||
};
|
||||
}
|
||||
|
||||
function mockViewsResponse(views: ViewProps[]): AxiosResponse<AllViewsProps> {
|
||||
return {
|
||||
data: { status: 'success', data: views },
|
||||
} as AxiosResponse<AllViewsProps>;
|
||||
}
|
||||
|
||||
function mockViewByIdResponse(
|
||||
view: ViewProps,
|
||||
): AxiosResponse<{ status: string; data: ViewProps }> {
|
||||
return {
|
||||
data: { status: 'success', data: view },
|
||||
} as AxiosResponse<{ status: string; data: ViewProps }>;
|
||||
}
|
||||
|
||||
describe('resourceRoute', () => {
|
||||
it('returns null for saved_view so async navigation is used', () => {
|
||||
expect(resourceRoute(ResourceType.saved_view, 'view-123')).toBeNull();
|
||||
});
|
||||
|
||||
it('routes channels to the edit page', () => {
|
||||
expect(resourceRoute(ResourceType.channel, 'channel-uuid-1')).toBe(
|
||||
'/settings/channels/edit/channel-uuid-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOpenResource', () => {
|
||||
it('reads entity from the action envelope', () => {
|
||||
expect(
|
||||
resolveActionEntity({
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open view',
|
||||
entity: SavedViewEntityDTO.traces,
|
||||
}),
|
||||
).toBe(SavedViewEntityDTO.traces);
|
||||
});
|
||||
|
||||
it('reads resource id from input.viewKey', () => {
|
||||
expect(
|
||||
resolveResourceId({
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open view',
|
||||
input: { viewKey: 'abc-123' },
|
||||
}),
|
||||
).toBe('abc-123');
|
||||
});
|
||||
|
||||
it('maps entity values to explorer data sources', () => {
|
||||
expect(entityToDataSource('logs')).toBe(DataSource.LOGS);
|
||||
expect(entityToDataSource('logs_explorer')).toBe(DataSource.LOGS);
|
||||
expect(entityToDataSource('traces')).toBe(DataSource.TRACES);
|
||||
});
|
||||
|
||||
it('prefers entity over signal for saved-view source hints', () => {
|
||||
expect(
|
||||
resolveSavedViewSourceHint({
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open view',
|
||||
entity: SavedViewEntityDTO.traces,
|
||||
signal: ApplyFilterSignalDTO.logs,
|
||||
}),
|
||||
).toBe(DataSource.TRACES);
|
||||
});
|
||||
|
||||
it('falls back to signal when entity is absent', () => {
|
||||
expect(
|
||||
resolveSavedViewSourceHint({
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open view',
|
||||
signal: ApplyFilterSignalDTO.metrics,
|
||||
}),
|
||||
).toBe(DataSource.METRICS);
|
||||
});
|
||||
|
||||
it('normalises saved-view resource types', () => {
|
||||
expect(
|
||||
resolveResourceType({
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open view',
|
||||
resourceType: 'saved-view',
|
||||
}),
|
||||
).toBe(ResourceType.saved_view);
|
||||
});
|
||||
|
||||
it('detects open-view actions from label when id is present in input', () => {
|
||||
expect(
|
||||
isSavedViewOpenAction({
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open view',
|
||||
input: { viewId: 'view-1' },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves channel type from notification_channel alias', () => {
|
||||
expect(
|
||||
resolveResourceType({
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open channel',
|
||||
resourceType: 'notification_channel',
|
||||
}),
|
||||
).toBe(ResourceType.channel);
|
||||
});
|
||||
|
||||
it('infers channel type from Open channel label when resourceId is present', () => {
|
||||
expect(
|
||||
resolveOpenResourceType({
|
||||
kind: MessageActionKindDTO.open_resource,
|
||||
label: 'Open channel',
|
||||
resourceId: 'channel-1',
|
||||
}),
|
||||
).toBe(ResourceType.channel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSavedViewInLists', () => {
|
||||
beforeEach(() => {
|
||||
mockedGetAllViews.mockReset();
|
||||
});
|
||||
|
||||
it('loads only the hinted source when entity is provided', async () => {
|
||||
const tracesView = makeView('view-traces', DataSource.TRACES);
|
||||
mockedGetAllViews.mockResolvedValueOnce(mockViewsResponse([tracesView]));
|
||||
|
||||
const result = await findSavedViewInLists('view-traces', DataSource.TRACES);
|
||||
|
||||
expect(result).toStrictEqual(tracesView);
|
||||
expect(mockedGetAllViews).toHaveBeenCalledTimes(1);
|
||||
expect(mockedGetAllViews).toHaveBeenCalledWith(DataSource.TRACES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildExplorerNavigationUrl', () => {
|
||||
it('encodes composite query and view selectors', () => {
|
||||
const url = buildExplorerNavigationUrl(
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
{ queryType: 'builder' } as never,
|
||||
{
|
||||
[QueryParams.panelTypes]: PANEL_TYPES.LIST,
|
||||
[QueryParams.viewName]: 'My view',
|
||||
[QueryParams.viewKey]: 'view-1',
|
||||
},
|
||||
);
|
||||
|
||||
expect(url).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(url).toContain(`${QueryParams.compositeQuery}=`);
|
||||
expect(url).toContain(`${QueryParams.viewKey}=`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openSavedView', () => {
|
||||
it('navigates with history.push and view query params', () => {
|
||||
const push = jest.fn();
|
||||
const history = { push } as unknown as History;
|
||||
const view = makeView('view-logs', DataSource.LOGS);
|
||||
|
||||
openSavedView(view, history);
|
||||
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
const pushedUrl = push.mock.calls[0][0] as string;
|
||||
expect(pushedUrl).toContain(ROUTES.LOGS_EXPLORER);
|
||||
expect(pushedUrl).toContain(QueryParams.viewKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openSavedViewByKey', () => {
|
||||
beforeEach(() => {
|
||||
mockedGetAllViews.mockReset();
|
||||
mockedGetViewById.mockReset();
|
||||
});
|
||||
|
||||
it('prefers the direct view lookup endpoint', async () => {
|
||||
const view = makeView('view-logs', DataSource.LOGS);
|
||||
mockedGetViewById.mockResolvedValueOnce(mockViewByIdResponse(view));
|
||||
const push = jest.fn();
|
||||
const history = { push } as unknown as History;
|
||||
|
||||
await openSavedViewByKey('view-logs', DataSource.LOGS, history);
|
||||
|
||||
expect(mockedGetViewById).toHaveBeenCalledWith('view-logs');
|
||||
expect(mockedGetAllViews).not.toHaveBeenCalled();
|
||||
expect(push).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to list probing when direct lookup fails', async () => {
|
||||
const view = makeView('view-traces', DataSource.TRACES);
|
||||
mockedGetViewById.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedGetAllViews.mockResolvedValueOnce(mockViewsResponse([view]));
|
||||
const push = jest.fn();
|
||||
const history = { push } as unknown as History;
|
||||
|
||||
await openSavedViewByKey('view-traces', DataSource.TRACES, history);
|
||||
|
||||
expect(mockedGetAllViews).toHaveBeenCalledWith(DataSource.TRACES);
|
||||
expect(push).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the saved view does not exist', async () => {
|
||||
mockedGetViewById.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedGetAllViews.mockResolvedValue(mockViewsResponse([]));
|
||||
|
||||
await expect(
|
||||
openSavedViewByKey('missing', DataSource.LOGS, {
|
||||
push: jest.fn(),
|
||||
} as unknown as History),
|
||||
).rejects.toThrow('Saved view not found');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { getAllViews } from 'api/saveView/getAllViews';
|
||||
import { getViewById } from 'api/saveView/getViewById';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import { ViewProps } from 'types/api/saveViews/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { History } from 'history';
|
||||
|
||||
type SavedViewSourceHint = DataSource | 'meter';
|
||||
|
||||
const DEFAULT_PROBE_SOURCES: SavedViewSourceHint[] = [
|
||||
DataSource.LOGS,
|
||||
DataSource.TRACES,
|
||||
DataSource.METRICS,
|
||||
];
|
||||
|
||||
export async function findSavedViewInLists(
|
||||
viewKey: string,
|
||||
sourceHint?: SavedViewSourceHint | null,
|
||||
): Promise<ViewProps | null> {
|
||||
const sources = sourceHint ? [sourceHint] : DEFAULT_PROBE_SOURCES;
|
||||
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const response = await getAllViews(source);
|
||||
const match = response.data.data.find((view) => view.id === viewKey);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
} catch {
|
||||
// Probe the next source page when no entity hint is provided.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadSavedView(
|
||||
viewKey: string,
|
||||
sourceHint?: SavedViewSourceHint | null,
|
||||
): Promise<ViewProps> {
|
||||
try {
|
||||
const response = await getViewById(viewKey);
|
||||
if (response.data?.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to list probing when the direct lookup fails.
|
||||
}
|
||||
|
||||
const fromList = await findSavedViewInLists(viewKey, sourceHint);
|
||||
if (fromList) {
|
||||
return fromList;
|
||||
}
|
||||
|
||||
throw new Error('Saved view not found');
|
||||
}
|
||||
|
||||
export function explorerRouteForSourcePage(
|
||||
sourcePage: DataSource | string,
|
||||
): (typeof SOURCEPAGE_VS_ROUTES)[keyof typeof SOURCEPAGE_VS_ROUTES] | null {
|
||||
return SOURCEPAGE_VS_ROUTES[sourcePage] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an explorer URL the same way `redirectWithQueryBuilderData` does —
|
||||
* without inheriting stale query params from the current page's `urlQuery`.
|
||||
*/
|
||||
export function buildExplorerNavigationUrl(
|
||||
route: string,
|
||||
query: Query,
|
||||
searchParams: Record<string, unknown>,
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(query)),
|
||||
);
|
||||
Object.entries(searchParams).forEach(([key, value]) => {
|
||||
params.set(key, JSON.stringify(value));
|
||||
});
|
||||
return `${route}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function openSavedView(view: ViewProps, history: History): void {
|
||||
const route = explorerRouteForSourcePage(view.sourcePage);
|
||||
if (!route) {
|
||||
throw new Error('Unsupported saved view source');
|
||||
}
|
||||
|
||||
if (!view.compositeQuery) {
|
||||
throw new Error('Saved view is missing query data');
|
||||
}
|
||||
|
||||
const query = mapQueryDataFromApi(view.compositeQuery);
|
||||
const url = buildExplorerNavigationUrl(route, query, {
|
||||
[QueryParams.panelTypes]: view.compositeQuery.panelType as PANEL_TYPES,
|
||||
[QueryParams.viewName]: view.name,
|
||||
[QueryParams.viewKey]: view.id,
|
||||
});
|
||||
history.push(url);
|
||||
}
|
||||
|
||||
export async function openSavedViewByKey(
|
||||
viewKey: string,
|
||||
sourceHint: SavedViewSourceHint | null | undefined,
|
||||
history: History,
|
||||
): Promise<void> {
|
||||
const view = await loadSavedView(viewKey, sourceHint);
|
||||
openSavedView(view, history);
|
||||
}
|
||||
|
||||
/** @deprecated Use findSavedViewInLists — kept for tests. */
|
||||
export const findSavedView = findSavedViewInLists;
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { MessageActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import {
|
||||
ApplyFilterSignalDTO,
|
||||
SavedViewEntityDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { ResourceType } from './resourceRoute';
|
||||
|
||||
function readString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
/** Normalises backend resource-type strings to the taxonomy used in the UI. */
|
||||
export function normalizeResourceType(
|
||||
resourceType: string | null | undefined,
|
||||
): string | null {
|
||||
if (!resourceType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = resourceType.trim().toLowerCase().replace(/-/g, '_');
|
||||
if (normalized === 'savedview') {
|
||||
return ResourceType.saved_view;
|
||||
}
|
||||
if (
|
||||
normalized === 'notification_channel' ||
|
||||
normalized === 'notificationchannel'
|
||||
) {
|
||||
return ResourceType.channel;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Reads a resource type from the action envelope or its `input` payload. */
|
||||
export function resolveResourceType(action: MessageActionDTO): string | null {
|
||||
const direct = normalizeResourceType(action.resourceType);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const input = action.input;
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
normalizeResourceType(readString(input.resourceType)) ??
|
||||
normalizeResourceType(readString(input.type))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the resource type for an `open_resource` action, including label-based
|
||||
* fallbacks when the backend only sends a display label + id.
|
||||
*/
|
||||
export function resolveOpenResourceType(
|
||||
action: MessageActionDTO,
|
||||
): string | null {
|
||||
const fromFields = resolveResourceType(action);
|
||||
if (fromFields) {
|
||||
return fromFields;
|
||||
}
|
||||
|
||||
if (/open\s+channel/i.test(action.label) && resolveResourceId(action)) {
|
||||
return ResourceType.channel;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Reads a resource id from `resourceId` or common `input` keys. */
|
||||
export function resolveResourceId(action: MessageActionDTO): string | null {
|
||||
const direct = readString(action.resourceId);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const input = action.input;
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const key of [
|
||||
'resourceId',
|
||||
'viewId',
|
||||
'viewKey',
|
||||
'channelId',
|
||||
'id',
|
||||
] as const) {
|
||||
const value = readString(input[key]);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Reads `entity` from the action envelope or its `input` payload. */
|
||||
export function resolveActionEntity(
|
||||
action: MessageActionDTO,
|
||||
): SavedViewEntityDTO | null {
|
||||
if (action.entity) {
|
||||
return action.entity;
|
||||
}
|
||||
|
||||
const fromInput = readString(action.input?.entity);
|
||||
if (!fromInput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeToSavedViewEntity(fromInput);
|
||||
}
|
||||
|
||||
function normalizeToSavedViewEntity(value: string): SavedViewEntityDTO | null {
|
||||
const source = entityToDataSource(value);
|
||||
switch (source) {
|
||||
case DataSource.LOGS:
|
||||
return SavedViewEntityDTO.logs;
|
||||
case DataSource.TRACES:
|
||||
return SavedViewEntityDTO.traces;
|
||||
case DataSource.METRICS:
|
||||
return SavedViewEntityDTO.metrics;
|
||||
case 'meter':
|
||||
return SavedViewEntityDTO.meter;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an action `entity` to an explorer `DataSource` for saved-view lookups.
|
||||
* Accepts both short (`logs`) and taxonomy (`logs_explorer`) values.
|
||||
*/
|
||||
export function entityToDataSource(
|
||||
entity: SavedViewEntityDTO | string,
|
||||
): DataSource | 'meter' | null {
|
||||
const normalized = entity.trim().toLowerCase().replace(/-/g, '_');
|
||||
|
||||
switch (normalized) {
|
||||
case SavedViewEntityDTO.logs:
|
||||
case ResourceType.logs_explorer:
|
||||
return DataSource.LOGS;
|
||||
case SavedViewEntityDTO.traces:
|
||||
case ResourceType.traces_explorer:
|
||||
return DataSource.TRACES;
|
||||
case SavedViewEntityDTO.metrics:
|
||||
case ResourceType.metrics_explorer:
|
||||
return DataSource.METRICS;
|
||||
case SavedViewEntityDTO.meter:
|
||||
return 'meter';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks which explorer source page to search when resolving a saved view.
|
||||
* Prefers `entity` (open_resource); falls back to `signal` only for legacy payloads.
|
||||
*/
|
||||
export function resolveSavedViewSourceHint(
|
||||
action: MessageActionDTO,
|
||||
): DataSource | 'meter' | null {
|
||||
const entity = resolveActionEntity(action);
|
||||
if (entity) {
|
||||
const fromEntity = entityToDataSource(entity);
|
||||
if (fromEntity) {
|
||||
return fromEntity;
|
||||
}
|
||||
}
|
||||
|
||||
if (action.signal) {
|
||||
switch (action.signal) {
|
||||
case ApplyFilterSignalDTO.logs:
|
||||
return DataSource.LOGS;
|
||||
case ApplyFilterSignalDTO.traces:
|
||||
return DataSource.TRACES;
|
||||
case ApplyFilterSignalDTO.metrics:
|
||||
return DataSource.METRICS;
|
||||
default: {
|
||||
const _exhaustive: never = action.signal;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isSavedViewOpenAction(action: MessageActionDTO): boolean {
|
||||
if (resolveResourceType(action) === ResourceType.saved_view) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Defensive: some agent payloads only set a human label + id in `input`.
|
||||
return /open\s+view/i.test(action.label) && resolveResourceId(action) !== null;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryParams } from 'constants/query';
|
||||
|
||||
/**
|
||||
* Resource-type strings the backend uses for `open_resource` and rollback
|
||||
* actions. Centralized here so route/module lookups stay in sync.
|
||||
*/
|
||||
export const ResourceType = {
|
||||
dashboard: 'dashboard',
|
||||
alert: 'alert',
|
||||
service: 'service',
|
||||
channel: 'channel',
|
||||
saved_view: 'saved_view',
|
||||
logs_explorer: 'logs_explorer',
|
||||
traces_explorer: 'traces_explorer',
|
||||
metrics_explorer: 'metrics_explorer',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resolves an `open_resource` action to an in-app route for synchronous
|
||||
* navigation. Returns `null` for `saved_view` — callers must load the view
|
||||
* by id and navigate with query-builder state instead.
|
||||
*/
|
||||
export function resourceRoute(
|
||||
resourceType: string,
|
||||
resourceId: string,
|
||||
): string | null {
|
||||
switch (resourceType) {
|
||||
case ResourceType.dashboard:
|
||||
return ROUTES.DASHBOARD.replace(':dashboardId', resourceId);
|
||||
case ResourceType.alert: {
|
||||
const params = new URLSearchParams({ [QueryParams.ruleId]: resourceId });
|
||||
return `${ROUTES.EDIT_ALERTS}?${params.toString()}`;
|
||||
}
|
||||
case ResourceType.service:
|
||||
return ROUTES.SERVICE_METRICS.replace(':servicename', resourceId);
|
||||
case ResourceType.channel:
|
||||
return ROUTES.CHANNELS_EDIT.replace(':channelId', resourceId);
|
||||
case ResourceType.saved_view:
|
||||
return null;
|
||||
case ResourceType.logs_explorer:
|
||||
return ROUTES.LOGS_EXPLORER;
|
||||
case ResourceType.traces_explorer:
|
||||
return ROUTES.TRACES_EXPLORER;
|
||||
case ResourceType.metrics_explorer:
|
||||
return ROUTES.METRICS_EXPLORER_EXPLORER;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,8 @@ function autoContextLabel(ctx: MessageContext): string {
|
||||
return 'Panel (fullscreen)';
|
||||
case 'dashboard_list':
|
||||
return 'Dashboards';
|
||||
case 'alert_detail':
|
||||
return 'Current alert';
|
||||
case 'alert_edit':
|
||||
return 'Editing alert';
|
||||
case 'alert_new':
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
type MarkdownExternalLinkProps = ComponentProps<'a'> & {
|
||||
// react-markdown passes `node` — accept and ignore it
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
node?: any;
|
||||
};
|
||||
|
||||
export default function MarkdownExternalLink({
|
||||
href,
|
||||
children,
|
||||
node: _node,
|
||||
...props
|
||||
}: MarkdownExternalLinkProps): JSX.Element {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="ai-markdown-link"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { Message, MessageBlock } from '../../types';
|
||||
import ActionsSection from '../ActionsSection';
|
||||
import ActivityGroup, { ActivityItem } from '../ActivityGroup';
|
||||
import { RichCodeBlock } from '../blocks';
|
||||
import MarkdownExternalLink from '../MarkdownExternalLink/MarkdownExternalLink';
|
||||
import { MessageContext } from '../MessageContext';
|
||||
import MessageFeedback from '../MessageFeedback';
|
||||
import UserMessageActions from '../UserMessageActions';
|
||||
@@ -37,7 +38,11 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
|
||||
}
|
||||
|
||||
const MD_PLUGINS = [remarkGfm];
|
||||
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
|
||||
const MD_COMPONENTS = {
|
||||
code: RichCodeBlock,
|
||||
pre: SmartPre,
|
||||
a: MarkdownExternalLink,
|
||||
};
|
||||
|
||||
type RenderGroup =
|
||||
| { kind: 'text'; id: string; content: string }
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
font-size: 10px;
|
||||
color: var(--l3-foreground);
|
||||
white-space: nowrap;
|
||||
padding-left: 2px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { StreamingEventItem } from '../../types';
|
||||
import ActivityGroup, { ActivityItem } from '../ActivityGroup';
|
||||
import ApprovalCard from '../ApprovalCard';
|
||||
import { RichCodeBlock } from '../blocks';
|
||||
import MarkdownExternalLink from '../MarkdownExternalLink/MarkdownExternalLink';
|
||||
import ClarificationForm from '../ClarificationForm';
|
||||
|
||||
import messageStyles from '../MessageBubble/MessageBubble.module.scss';
|
||||
@@ -30,7 +31,11 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
|
||||
}
|
||||
|
||||
const MD_PLUGINS = [remarkGfm];
|
||||
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
|
||||
const MD_COMPONENTS = {
|
||||
code: RichCodeBlock,
|
||||
pre: SmartPre,
|
||||
a: MarkdownExternalLink,
|
||||
};
|
||||
|
||||
type RenderGroup =
|
||||
| { kind: 'text'; id: string; content: string }
|
||||
|
||||
@@ -99,6 +99,30 @@ export function getAutoContexts(
|
||||
|
||||
// ── Alerts ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Alert detail (overview / per-rule history) — `/alerts/overview?ruleId=…`
|
||||
// or `/alerts/history?ruleId=…`. Mirrors dashboard_detail: resourceId is the
|
||||
// rule id and shared metadata carries the URL time range when present.
|
||||
if (
|
||||
matchPath(pathname, { path: ROUTES.ALERT_OVERVIEW, exact: true }) ||
|
||||
matchPath(pathname, { path: ROUTES.ALERT_HISTORY, exact: true })
|
||||
) {
|
||||
const ruleId = params.get(QueryParams.ruleId);
|
||||
if (ruleId) {
|
||||
return [
|
||||
{
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: ruleId,
|
||||
metadata: {
|
||||
page: 'alert_detail',
|
||||
ruleId,
|
||||
...sharedMetadata,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Alert edit — `/alerts/edit?ruleId=…`.
|
||||
if (matchPath(pathname, { path: ROUTES.EDIT_ALERTS, exact: true })) {
|
||||
const ruleId = params.get(QueryParams.ruleId);
|
||||
@@ -108,7 +132,7 @@ export function getAutoContexts(
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: ruleId,
|
||||
metadata: { page: 'alert_edit' },
|
||||
metadata: { page: 'alert_edit', ruleId },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -125,6 +149,7 @@ export function getAutoContexts(
|
||||
];
|
||||
}
|
||||
|
||||
// Triggered-alerts index — `/alerts/history` without a rule id.
|
||||
if (matchPath(pathname, { path: ROUTES.ALERT_HISTORY, exact: true })) {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import axios from 'axios';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
|
||||
import type {
|
||||
ErrorResponseDTO,
|
||||
MessageActionDTO,
|
||||
MessageSummaryDTOBlocksAnyOfItem,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
@@ -37,6 +35,7 @@ import {
|
||||
MessageBlock,
|
||||
MessageRole,
|
||||
} from '../types';
|
||||
import { resolveAssistantErrorMessage } from '../utils/resolveAssistantErrorMessage';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types used by module-level helpers
|
||||
@@ -399,6 +398,7 @@ async function runStreamingLoop(
|
||||
}
|
||||
throw Object.assign(new Error(event.error.message), {
|
||||
retryAction: event.retryAction,
|
||||
code: event.error.code,
|
||||
});
|
||||
} else if (event.type === 'conversation' && event.title) {
|
||||
set((s) => {
|
||||
@@ -484,36 +484,6 @@ function hasPendingInput(conversationId: string, get: StoreGetter): boolean {
|
||||
return Boolean(stream?.pendingApproval || stream?.pendingClarification);
|
||||
}
|
||||
|
||||
function parseErrorBody(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return parseErrorBody(JSON.parse(value));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const message = (value as ErrorResponseDTO | undefined)?.error?.message;
|
||||
return typeof message === 'string' && message.length > 0 ? message : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the backend's `error.message` when `err` is a 429 axios response
|
||||
* (typically from the threads API surface — createThread, sendMessage, approve,
|
||||
* clarify, regenerate). Returns null for any other error so callers fall
|
||||
* through to their generic copy.
|
||||
*/
|
||||
function rateLimitMessage(err: unknown): string | null {
|
||||
if (axios.isAxiosError(err) && err.response?.status === 429) {
|
||||
return parseErrorBody(err.response.data);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits an error message and removes the stream entry. When `isRateLimit`
|
||||
* is true, the committed message is flagged so the feedback/regenerate bar
|
||||
* is hidden — clicking regenerate would just 429 again.
|
||||
*/
|
||||
function finalizeStreamingError(
|
||||
conversationId: string,
|
||||
errorContent: string,
|
||||
@@ -1174,14 +1144,11 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] sendMessage failed:', err);
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
convId,
|
||||
rateLimit ??
|
||||
'Something went wrong while fetching the response. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while fetching the response. Please try again.',
|
||||
);
|
||||
finalizeStreamingError(convId, message, set, isRateLimit);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1214,14 +1181,11 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] approveAction failed:', err);
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
conversationId,
|
||||
rateLimit ??
|
||||
'Something went wrong while processing the approval. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while processing the approval. Please try again.',
|
||||
);
|
||||
finalizeStreamingError(conversationId, message, set, isRateLimit);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1296,14 +1260,11 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] regenerateAssistantMessage failed:', err);
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
conversationId,
|
||||
rateLimit ??
|
||||
'Something went wrong while regenerating the response. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while regenerating the response. Please try again.',
|
||||
);
|
||||
finalizeStreamingError(conversationId, message, set, isRateLimit);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1365,14 +1326,11 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] submitClarification failed:', err);
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
conversationId,
|
||||
rateLimit ??
|
||||
'Something went wrong while processing your answers. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while processing your answers. Please try again.',
|
||||
);
|
||||
finalizeStreamingError(conversationId, message, set, isRateLimit);
|
||||
}
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorCodeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
import { resolveAssistantErrorMessage } from '../resolveAssistantErrorMessage';
|
||||
|
||||
const FALLBACK = 'Something went wrong. Please try again.';
|
||||
|
||||
describe('resolveAssistantErrorMessage', () => {
|
||||
it('returns backend message for a known error code', () => {
|
||||
const err = new AxiosError('Request failed');
|
||||
err.response = {
|
||||
status: 400,
|
||||
data: {
|
||||
error: {
|
||||
code: ErrorCodeDTO.thread_busy,
|
||||
message: 'This thread is busy. Try again shortly.',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: 'This thread is busy. Try again shortly.',
|
||||
isRateLimit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back when error code is not in ErrorCodeDTO', () => {
|
||||
const err = new AxiosError('Request failed');
|
||||
err.response = {
|
||||
status: 400,
|
||||
data: {
|
||||
error: {
|
||||
code: 'future_unknown_code',
|
||||
message: 'Backend-only message',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: FALLBACK,
|
||||
isRateLimit: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks HTTP 429 responses as rate limited', () => {
|
||||
const err = new AxiosError('Too many requests');
|
||||
err.response = {
|
||||
status: 429,
|
||||
data: {
|
||||
error: {
|
||||
code: ErrorCodeDTO.hourly_message_limit,
|
||||
message: 'Hourly limit reached.',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: 'Hourly limit reached.',
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses backend message for known SSE rate-limit error codes', () => {
|
||||
const err = Object.assign(new Error('Daily token limit exceeded.'), {
|
||||
code: ErrorCodeDTO.daily_token_limit,
|
||||
});
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: 'Daily token limit exceeded.',
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks 429 as rate limited even when error code is unknown', () => {
|
||||
const err = new AxiosError('Too many requests');
|
||||
err.response = {
|
||||
status: 429,
|
||||
data: {
|
||||
error: {
|
||||
code: 'future_unknown_code',
|
||||
message: 'Too many requests',
|
||||
},
|
||||
},
|
||||
} as AxiosError['response'];
|
||||
|
||||
expect(resolveAssistantErrorMessage(err, FALLBACK)).toStrictEqual({
|
||||
message: FALLBACK,
|
||||
isRateLimit: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { isAxiosError } from 'axios';
|
||||
import {
|
||||
ErrorCodeDTO,
|
||||
type ErrorBodyDTO,
|
||||
type ErrorResponseDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
|
||||
export interface AssistantErrorResolution {
|
||||
message: string;
|
||||
isRateLimit: boolean;
|
||||
}
|
||||
|
||||
function isErrorCodeDTO(code: string | undefined): code is ErrorCodeDTO {
|
||||
return (
|
||||
code !== undefined && (Object.values(ErrorCodeDTO) as string[]).includes(code)
|
||||
);
|
||||
}
|
||||
|
||||
const RATE_LIMIT_ERROR_CODES = new Set<ErrorCodeDTO>([
|
||||
ErrorCodeDTO.rate_limit_override_exceeds_ceiling,
|
||||
ErrorCodeDTO.thread_message_limit,
|
||||
ErrorCodeDTO.connection_limit_exceeded,
|
||||
ErrorCodeDTO.hourly_message_limit,
|
||||
ErrorCodeDTO.daily_message_limit,
|
||||
ErrorCodeDTO.daily_token_limit,
|
||||
ErrorCodeDTO.daily_cost_limit,
|
||||
ErrorCodeDTO.budget_exceeded,
|
||||
]);
|
||||
|
||||
function isRateLimitError(code: string | undefined, err: unknown): boolean {
|
||||
if (isAxiosError(err) && err.response?.status === 429) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isErrorCodeDTO(code) && RATE_LIMIT_ERROR_CODES.has(code);
|
||||
}
|
||||
|
||||
function getErrorBody(err: unknown): ErrorBodyDTO | null {
|
||||
if (isAxiosError(err)) {
|
||||
return (err.response?.data as ErrorResponseDTO | undefined)?.error ?? null;
|
||||
}
|
||||
|
||||
const code = (err as { code?: string } | undefined)?.code;
|
||||
const message = err instanceof Error ? err.message : undefined;
|
||||
if (!code || !message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { code: code as ErrorCodeDTO, message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses `error.message` when `error.code` is a known `ErrorCodeDTO`;
|
||||
* otherwise returns `fallback`.
|
||||
*/
|
||||
export function resolveAssistantErrorMessage(
|
||||
err: unknown,
|
||||
fallback: string,
|
||||
): AssistantErrorResolution {
|
||||
const body = getErrorBody(err);
|
||||
const isRateLimit = isRateLimitError(body?.code, err);
|
||||
|
||||
if (body && isErrorCodeDTO(body.code) && body.message.trim()) {
|
||||
return {
|
||||
message: body.message.trim(),
|
||||
isRateLimit,
|
||||
};
|
||||
}
|
||||
|
||||
return { message: fallback, isRateLimit: Boolean(isRateLimit) };
|
||||
}
|
||||
@@ -29,3 +29,7 @@
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
body.ai-assistant-panel-open .create-alert-v2-footer {
|
||||
right: var(--ai-assistant-panel-width, 380px);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
.right-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Flex } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { adjustQueryForV5 } from 'components/QueryBuilderV2/utils';
|
||||
import { QueryParams } from 'constants/query';
|
||||
@@ -820,6 +821,11 @@ function NewWidget({
|
||||
</Flex>
|
||||
</div>
|
||||
<div className="right-header">
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
/>
|
||||
{showSwitchToViewModeButton && (
|
||||
<Button
|
||||
color="primary"
|
||||
|
||||
Reference in New Issue
Block a user