mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-16 21:40:34 +01:00
Compare commits
1 Commits
fix/ai-ass
...
nv/dashboa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86fc0e81ba |
@@ -159,25 +159,6 @@ 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;
|
||||
@@ -405,74 +386,15 @@ 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 {
|
||||
code: ErrorCodeDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @pattern ^[a-z_]+$
|
||||
*/
|
||||
code: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -568,23 +490,6 @@ 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',
|
||||
@@ -595,7 +500,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. open_resource for a saved view also carries entity (logs/traces/metrics/meter) so the frontend routes to the correct Explorer.
|
||||
* 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.
|
||||
*/
|
||||
export interface MessageActionDTO {
|
||||
kind: MessageActionKindDTO;
|
||||
@@ -612,7 +517,6 @@ export interface MessageActionDTO {
|
||||
signal?: MessageActionDTOSignal;
|
||||
query?: MessageActionDTOQuery;
|
||||
url?: MessageActionDTOUrl;
|
||||
entity?: MessageActionDTOEntity;
|
||||
}
|
||||
|
||||
export enum MessageContentTypeDTO {
|
||||
@@ -686,26 +590,6 @@ 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',
|
||||
@@ -1106,10 +990,8 @@ 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. open_resource for a saved view also carries entity (logs/traces/metrics/meter) so the frontend routes to the correct Explorer.
|
||||
* 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.
|
||||
*/
|
||||
export interface MessageActionEventDTO {
|
||||
kind: MessageActionKindDTO;
|
||||
@@ -1126,7 +1008,6 @@ export interface MessageActionEventDTO {
|
||||
signal?: MessageActionEventDTOSignal;
|
||||
query?: MessageActionEventDTOQuery;
|
||||
url?: MessageActionEventDTOUrl;
|
||||
entity?: MessageActionEventDTOEntity;
|
||||
}
|
||||
|
||||
export type MessageEventDTOActions = MessageActionEventDTO[] | null;
|
||||
@@ -1504,21 +1385,3 @@ 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;
|
||||
};
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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}`);
|
||||
@@ -1,89 +0,0 @@
|
||||
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,22 +47,6 @@ import { AIAssistantEvents, SuggestedPromptCategory } from '../../events';
|
||||
import { useAIAssistantAnalyticsContext } from '../../hooks/useAIAssistantAnalyticsContext';
|
||||
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
|
||||
|
||||
import {
|
||||
getPanelTypeForRequestType,
|
||||
requestTypeFromActionQuery,
|
||||
} from './utils/applyFilterPanelType';
|
||||
import {
|
||||
buildExplorerNavigationUrl,
|
||||
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 {
|
||||
@@ -71,6 +55,20 @@ 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) {
|
||||
@@ -80,8 +78,6 @@ 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:
|
||||
@@ -144,6 +140,39 @@ 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:
|
||||
*
|
||||
@@ -330,41 +359,48 @@ function withDerivedFilterExpressions(query: Query): Query {
|
||||
* the new URL on mount.
|
||||
*/
|
||||
function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[apply_filter] enter', {
|
||||
signal: action.signal,
|
||||
query: action.query,
|
||||
pathname: deps.pathname,
|
||||
});
|
||||
if (!action.signal || !action.query) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[apply_filter] bail: missing signal or query', action);
|
||||
return;
|
||||
}
|
||||
const urlQuery = toUrlCompositeQuery(action.query as Record<string, unknown>);
|
||||
if (!urlQuery) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'[apply_filter] bail: toUrlCompositeQuery returned null — agent payload shape unrecognized',
|
||||
action.query,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// `requestType` lives on the request envelope, which `toUrlCompositeQuery`
|
||||
// drops — read it off the raw action query and translate it into the
|
||||
// explorer panel type so a grouped/aggregated query opens as a table/graph
|
||||
// instead of the default raw-log List view.
|
||||
const panelType = getPanelTypeForRequestType(
|
||||
requestTypeFromActionQuery(action.query as Record<string, unknown>),
|
||||
);
|
||||
const normalized = withDerivedFilterExpressions(urlQuery as unknown as Query);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[apply_filter] normalized', normalized);
|
||||
if (signalMatchesPathname(action.signal, deps.pathname)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[apply_filter] on-page → handleSetQueryData + redirect');
|
||||
normalized.builder.queryData.forEach((q, i) => {
|
||||
deps.handleSetQueryData(i, q);
|
||||
});
|
||||
deps.redirectWithQueryBuilderData(normalized, {
|
||||
[QueryParams.panelTypes]: panelType,
|
||||
});
|
||||
deps.redirectWithQueryBuilderData(normalized);
|
||||
return;
|
||||
}
|
||||
const base = explorerRouteForSignal(action.signal);
|
||||
if (!base) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[apply_filter] bail: no route for signal', action.signal);
|
||||
return;
|
||||
}
|
||||
// Reuse the saved-view URL builder so the encoding (double-encoded
|
||||
// compositeQuery + JSON-stringified panelTypes) matches what the explorer's
|
||||
// URL parser expects — see useGetCompositeQueryParam / useGetPanelTypesQueryParam.
|
||||
const url = buildExplorerNavigationUrl(base, normalized, {
|
||||
[QueryParams.panelTypes]: panelType,
|
||||
});
|
||||
deps.history.push(url);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[apply_filter] off-page → history.push', base);
|
||||
const encoded = encodeURIComponent(JSON.stringify(normalized));
|
||||
deps.history.push(`${base}?${QueryParams.compositeQuery}=${encoded}`);
|
||||
}
|
||||
|
||||
/** Picks the right rollback API call for a given action kind. */
|
||||
@@ -448,35 +484,6 @@ 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,
|
||||
@@ -495,31 +502,6 @@ 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: {
|
||||
@@ -560,9 +542,21 @@ export default function ActionsSection({
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MessageActionKindDTO.open_resource:
|
||||
handleOpenResource(key, action);
|
||||
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);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MessageActionKindDTO.undo:
|
||||
case MessageActionKindDTO.revert:
|
||||
case MessageActionKindDTO.restore: {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import {
|
||||
getPanelTypeForRequestType,
|
||||
requestTypeFromActionQuery,
|
||||
} from '../applyFilterPanelType';
|
||||
|
||||
describe('getPanelTypeForRequestType', () => {
|
||||
it('maps scalar (grouped aggregation) to the Table view', () => {
|
||||
expect(getPanelTypeForRequestType('scalar')).toBe(PANEL_TYPES.TABLE);
|
||||
});
|
||||
|
||||
it('maps time_series to the Time Series (graph) view', () => {
|
||||
expect(getPanelTypeForRequestType('time_series')).toBe(
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
);
|
||||
});
|
||||
|
||||
it('maps distribution (aggregation) to the Table view', () => {
|
||||
expect(getPanelTypeForRequestType('distribution')).toBe(PANEL_TYPES.TABLE);
|
||||
});
|
||||
|
||||
it('maps raw to the List view', () => {
|
||||
expect(getPanelTypeForRequestType('raw')).toBe(PANEL_TYPES.LIST);
|
||||
});
|
||||
|
||||
it.each([undefined, null, '', 'trace', 'nonsense', 42, {}])(
|
||||
'defaults to the List view for raw/unknown/missing requestType (%p)',
|
||||
(value) => {
|
||||
expect(getPanelTypeForRequestType(value)).toBe(PANEL_TYPES.LIST);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('requestTypeFromActionQuery', () => {
|
||||
it('reads the top-level requestType envelope field', () => {
|
||||
expect(
|
||||
requestTypeFromActionQuery({
|
||||
requestType: 'scalar',
|
||||
schemaVersion: 'v5',
|
||||
compositeQuery: { queries: [] },
|
||||
}),
|
||||
).toBe('scalar');
|
||||
});
|
||||
|
||||
it('returns undefined when the field or query is absent', () => {
|
||||
expect(requestTypeFromActionQuery({})).toBeUndefined();
|
||||
expect(requestTypeFromActionQuery(null)).toBeUndefined();
|
||||
expect(requestTypeFromActionQuery(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('composes with getPanelTypeForRequestType for the reported bug payload', () => {
|
||||
// The "log count by service" apply_filter payload from issue #304 follow-up:
|
||||
// scalar + groupBy(service.name) must open the Table view, not List.
|
||||
const query = {
|
||||
requestType: 'scalar',
|
||||
schemaVersion: 'v5',
|
||||
compositeQuery: {
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
signal: 'logs',
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
groupBy: [{ name: 'service.name' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(getPanelTypeForRequestType(requestTypeFromActionQuery(query))).toBe(
|
||||
PANEL_TYPES.TABLE,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,297 +0,0 @@
|
||||
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}=`);
|
||||
});
|
||||
|
||||
// Regression guard for the apply_filter view bug: the panel type must land
|
||||
// on the URL JSON-encoded the way `useGetPanelTypesQueryParam` reads it
|
||||
// (`JSON.parse` of the param), i.e. as the quoted string `"table"` ->
|
||||
// `panelTypes=%22table%22`. Without this the explorer falls back to LIST.
|
||||
it('JSON-encodes panelTypes so the explorer opens the right view', () => {
|
||||
const url = buildExplorerNavigationUrl(
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
{ queryType: 'builder' } as never,
|
||||
{ [QueryParams.panelTypes]: PANEL_TYPES.TABLE },
|
||||
);
|
||||
|
||||
expect(url).toContain(`${QueryParams.panelTypes}=%22table%22`);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { REQUEST_TYPES } from 'api/v5/queryRange/constants';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { RequestType } from 'types/api/v5/queryRange';
|
||||
|
||||
/**
|
||||
* Maps an apply_filter `query.requestType` to the explorer panel type so the
|
||||
* Explorer opens in the view the query implies:
|
||||
*
|
||||
* - `scalar` -> Table (grouped aggregation, e.g. "count by service")
|
||||
* - `distribution` -> Table (aggregation; Logs/Traces have no histogram view)
|
||||
* - `time_series` -> Time Series (graph)
|
||||
* - `raw` / other -> List (raw rows) [default]
|
||||
*
|
||||
* `trace` and the empty request type fall through to the List default on
|
||||
* purpose — they are raw, ungrouped result sets.
|
||||
*
|
||||
* The agent emits `requestType` on the request envelope of `action.query`. It
|
||||
* must be read off the raw action query *before* `toUrlCompositeQuery` maps the
|
||||
* inner `compositeQuery` (that mapper keeps only the builder queries and drops
|
||||
* the envelope). Without an explicit `panelTypes` URL param the Explorer falls
|
||||
* back to `PANEL_TYPES.LIST` (see `useGetPanelTypesQueryParam`), so a grouped
|
||||
* "count by service" query renders as a raw log list instead of a table.
|
||||
*/
|
||||
const REQUEST_TYPE_TO_PANEL_TYPE: Partial<Record<RequestType, PANEL_TYPES>> = {
|
||||
[REQUEST_TYPES.SCALAR]: PANEL_TYPES.TABLE,
|
||||
[REQUEST_TYPES.DISTRIBUTION]: PANEL_TYPES.TABLE,
|
||||
[REQUEST_TYPES.TIME_SERIES]: PANEL_TYPES.TIME_SERIES,
|
||||
[REQUEST_TYPES.RAW]: PANEL_TYPES.LIST,
|
||||
};
|
||||
|
||||
export function getPanelTypeForRequestType(requestType: unknown): PANEL_TYPES {
|
||||
if (typeof requestType === 'string') {
|
||||
const mapped = REQUEST_TYPE_TO_PANEL_TYPE[requestType as RequestType];
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
return PANEL_TYPES.LIST;
|
||||
}
|
||||
|
||||
/** Reads the `requestType` envelope field off a raw apply_filter query payload. */
|
||||
export function requestTypeFromActionQuery(
|
||||
query: Record<string, unknown> | null | undefined,
|
||||
): unknown {
|
||||
return query?.requestType;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
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;
|
||||
@@ -1,203 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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,8 +101,6 @@ 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':
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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,7 +11,6 @@ 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';
|
||||
@@ -38,11 +37,7 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
|
||||
}
|
||||
|
||||
const MD_PLUGINS = [remarkGfm];
|
||||
const MD_COMPONENTS = {
|
||||
code: RichCodeBlock,
|
||||
pre: SmartPre,
|
||||
a: MarkdownExternalLink,
|
||||
};
|
||||
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
|
||||
|
||||
type RenderGroup =
|
||||
| { kind: 'text'; id: string; content: string }
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
font-size: 10px;
|
||||
color: var(--l3-foreground);
|
||||
white-space: nowrap;
|
||||
padding-left: 8px;
|
||||
padding-left: 2px;
|
||||
border-left: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ 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';
|
||||
@@ -31,11 +30,7 @@ function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
|
||||
}
|
||||
|
||||
const MD_PLUGINS = [remarkGfm];
|
||||
const MD_COMPONENTS = {
|
||||
code: RichCodeBlock,
|
||||
pre: SmartPre,
|
||||
a: MarkdownExternalLink,
|
||||
};
|
||||
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
|
||||
|
||||
type RenderGroup =
|
||||
| { kind: 'text'; id: string; content: string }
|
||||
|
||||
@@ -99,30 +99,6 @@ 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);
|
||||
@@ -132,7 +108,7 @@ export function getAutoContexts(
|
||||
source: 'auto',
|
||||
type: 'alert',
|
||||
resourceId: ruleId,
|
||||
metadata: { page: 'alert_edit', ruleId },
|
||||
metadata: { page: 'alert_edit' },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -149,7 +125,6 @@ export function getAutoContexts(
|
||||
];
|
||||
}
|
||||
|
||||
// Triggered-alerts index — `/alerts/history` without a rule id.
|
||||
if (matchPath(pathname, { path: ROUTES.ALERT_HISTORY, exact: true })) {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/* 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';
|
||||
@@ -35,7 +37,6 @@ import {
|
||||
MessageBlock,
|
||||
MessageRole,
|
||||
} from '../types';
|
||||
import { resolveAssistantErrorMessage } from '../utils/resolveAssistantErrorMessage';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types used by module-level helpers
|
||||
@@ -398,7 +399,6 @@ 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,6 +484,36 @@ 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,
|
||||
@@ -1144,11 +1174,14 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] sendMessage failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while fetching the response. Please try again.',
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
convId,
|
||||
rateLimit ??
|
||||
'Something went wrong while fetching the response. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
);
|
||||
finalizeStreamingError(convId, message, set, isRateLimit);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1181,11 +1214,14 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] approveAction failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while processing the approval. Please try again.',
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
conversationId,
|
||||
rateLimit ??
|
||||
'Something went wrong while processing the approval. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
);
|
||||
finalizeStreamingError(conversationId, message, set, isRateLimit);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1260,11 +1296,14 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] regenerateAssistantMessage failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while regenerating the response. Please try again.',
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
conversationId,
|
||||
rateLimit ??
|
||||
'Something went wrong while regenerating the response. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
);
|
||||
finalizeStreamingError(conversationId, message, set, isRateLimit);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1326,11 +1365,14 @@ export const useAIAssistantStore = create<AIAssistantStore>()(
|
||||
return;
|
||||
}
|
||||
console.error('[AIAssistant] submitClarification failed:', err);
|
||||
const { message, isRateLimit } = resolveAssistantErrorMessage(
|
||||
err,
|
||||
'Something went wrong while processing your answers. Please try again.',
|
||||
const rateLimit = rateLimitMessage(err);
|
||||
finalizeStreamingError(
|
||||
conversationId,
|
||||
rateLimit ??
|
||||
'Something went wrong while processing your answers. Please try again.',
|
||||
set,
|
||||
rateLimit !== null,
|
||||
);
|
||||
finalizeStreamingError(conversationId, message, set, isRateLimit);
|
||||
}
|
||||
},
|
||||
})),
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
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,7 +29,3 @@
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
body.ai-assistant-panel-open .create-alert-v2-footer {
|
||||
right: var(--ai-assistant-panel-width, 380px);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@
|
||||
.right-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ 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';
|
||||
@@ -821,11 +820,6 @@ function NewWidget({
|
||||
</Flex>
|
||||
</div>
|
||||
<div className="right-header">
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
/>
|
||||
{showSwitchToViewModeButton && (
|
||||
<Button
|
||||
color="primary"
|
||||
|
||||
@@ -28,40 +28,43 @@ import { USER_ROLES } from 'types/roles';
|
||||
import ConfirmDeleteDialog from '../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
|
||||
import DashboardSettings from '../../DashboardSettings';
|
||||
import SettingsDrawer from '../SettingsDrawer';
|
||||
import styles from './DashboardActions.module.scss';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import styles from '../DashboardDescription.module.scss';
|
||||
|
||||
interface DashboardActionsProps {
|
||||
title: string;
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
handle: FullScreenHandle;
|
||||
isDashboardLocked: boolean;
|
||||
editDashboard: boolean;
|
||||
isAuthor: boolean;
|
||||
addPanelPermission: boolean;
|
||||
onAddPanel: () => void;
|
||||
onLockToggle: () => void;
|
||||
onOpenRename: () => void;
|
||||
}
|
||||
|
||||
function DashboardActions({
|
||||
title,
|
||||
dashboard,
|
||||
handle,
|
||||
isDashboardLocked,
|
||||
editDashboard,
|
||||
isAuthor,
|
||||
addPanelPermission,
|
||||
onAddPanel,
|
||||
onLockToggle,
|
||||
onOpenRename,
|
||||
}: DashboardActionsProps): JSX.Element {
|
||||
const canEdit = useDashboardStore((s) => s.isEditable);
|
||||
const { user } = useAppContext();
|
||||
const { t } = useTranslation(['dashboard', 'common']);
|
||||
|
||||
const id = dashboard.id ?? '';
|
||||
const title = dashboard.spec?.display?.name ?? '';
|
||||
|
||||
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const [state, setCopy] = useCopyToClipboard();
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState<boolean>(false);
|
||||
const deleteDashboardMutation = useDeleteDashboard(dashboard.id);
|
||||
const deleteDashboardMutation = useDeleteDashboard(id);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.error) {
|
||||
@@ -100,7 +103,7 @@ function DashboardActions({
|
||||
|
||||
const menuItems = useMemo<MenuItem[]>(() => {
|
||||
const editGroup: MenuItem[] = [];
|
||||
if (canEdit) {
|
||||
if (!isDashboardLocked && editDashboard) {
|
||||
editGroup.push({
|
||||
key: 'rename',
|
||||
label: 'Rename',
|
||||
@@ -156,6 +159,7 @@ function DashboardActions({
|
||||
);
|
||||
}, [
|
||||
isDashboardLocked,
|
||||
editDashboard,
|
||||
isAuthor,
|
||||
user.role,
|
||||
dashboard.createdBy,
|
||||
@@ -165,60 +169,58 @@ function DashboardActions({
|
||||
exportJSON,
|
||||
setCopy,
|
||||
dashboardDataJSON,
|
||||
canEdit,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardActionsContainer}>
|
||||
<div className={styles.rightSection}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
<div className={styles.dashboardActionsSecondary}>
|
||||
<DropdownMenuSimple menu={{ items: menuItems }}>
|
||||
<DropdownMenuSimple menu={{ items: menuItems }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
prefix={<Ellipsis size={14} />}
|
||||
className={styles.icons}
|
||||
testId="options"
|
||||
/>
|
||||
</DropdownMenuSimple>
|
||||
{!isDashboardLocked && editDashboard && (
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
prefix={<Ellipsis size="md" />}
|
||||
testId="options"
|
||||
/>
|
||||
</DropdownMenuSimple>
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
onClick={(): void => setIsSettingsDrawerOpen(true)}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={(): void => setIsSettingsDrawerOpen(false)}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
</>
|
||||
)}
|
||||
{!isDashboardLocked && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
onClick={(): void => setIsSettingsDrawerOpen(true)}
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
Configure
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={(): void => setIsSettingsDrawerOpen(false)}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
</>
|
||||
)}
|
||||
{!isDashboardLocked && addPanelPermission && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
)}
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteOpen}
|
||||
title={`Delete dashboard"?`}
|
||||
description={`Are you sure you want to delete this dashboard - "${title}"? This action cannot be undone.`}
|
||||
title={`Delete dashboard "${title}"?`}
|
||||
description="This action cannot be undone."
|
||||
isLoading={deleteDashboardMutation.isLoading}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onClose={(): void => setIsDeleteOpen(false)}
|
||||
@@ -0,0 +1,210 @@
|
||||
.dashboardDescriptionContainer {
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
background: unset;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
:global(.ant-card-body) {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.dashboardDetails {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 16px 16px 0px 16px;
|
||||
align-items: flex-start;
|
||||
|
||||
.leftSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 45%;
|
||||
height: 40px;
|
||||
|
||||
.dashboardImg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px; /* 150% */
|
||||
letter-spacing: -0.08px;
|
||||
max-width: 80%;
|
||||
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.clickableTitle {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.titleEdit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.titleInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.titleEditActionButton {
|
||||
--button-height: auto;
|
||||
--button-padding: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.titleSaveActionButton {
|
||||
--button-border-color: var(--text-forest-700);
|
||||
--button-outlined-foreground: var(--text-forest-700);
|
||||
}
|
||||
|
||||
.publicDashboardIcon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.rightSection {
|
||||
display: flex;
|
||||
width: 55%;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
height: 40px;
|
||||
|
||||
.icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 32px;
|
||||
height: 34px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 10px; /* 83.333% */
|
||||
letter-spacing: 0.12px;
|
||||
}
|
||||
|
||||
.icons:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardTags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 16px 16px 0px 16px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 20px;
|
||||
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
|
||||
color: var(--bg-sienna-400);
|
||||
text-align: center;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardDescriptionSection {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 22px; /* 157.143% */
|
||||
letter-spacing: -0.07px;
|
||||
padding: 20px 16px 0px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardSettings {
|
||||
width: 191px;
|
||||
height: 302px;
|
||||
flex-shrink: 0;
|
||||
|
||||
:global(.ant-popover-inner) {
|
||||
padding: 0px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 80%, transparent) 0%,
|
||||
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
|
||||
) !important;
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.menuContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: unset;
|
||||
padding: 8px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
.section1,
|
||||
.section2 {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.deleteDashboard button {
|
||||
color: var(--bg-cherry-400) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.deleteModal :global(.ant-modal-confirm-body) {
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
|
||||
import styles from '../DashboardDescription.module.scss';
|
||||
|
||||
interface DashboardMetaProps {
|
||||
tags: string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
function DashboardMeta({ tags, description }: DashboardMetaProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{tags.length > 0 && (
|
||||
<div className={styles.dashboardTags}>
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} className={styles.tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty(description) && (
|
||||
<section className={styles.dashboardDescriptionSection}>
|
||||
{description}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardMeta;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { KeyboardEvent } from 'react';
|
||||
import { Check, Globe, LockKeyhole, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from '../DashboardDescription.module.scss';
|
||||
|
||||
interface DashboardTitleProps {
|
||||
title: string;
|
||||
image: string;
|
||||
isPublicDashboard: boolean;
|
||||
isDashboardLocked: boolean;
|
||||
isEditable: boolean;
|
||||
isEditing: boolean;
|
||||
draft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
onStartEdit: () => void;
|
||||
onCommit: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function DashboardTitle({
|
||||
title,
|
||||
image,
|
||||
isPublicDashboard,
|
||||
isDashboardLocked,
|
||||
isEditable,
|
||||
isEditing,
|
||||
draft,
|
||||
onDraftChange,
|
||||
onStartEdit,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: DashboardTitleProps): JSX.Element {
|
||||
const canEdit = isEditable && !isDashboardLocked;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onCommit();
|
||||
} else if (event.key === 'Escape') {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.leftSection}>
|
||||
<img src={image} alt="dashboard-img" className={styles.dashboardImg} />
|
||||
{isEditing ? (
|
||||
<div className={styles.titleEdit}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={120}
|
||||
className={styles.titleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
size="icon"
|
||||
className={cx(styles.titleEditActionButton, styles.titleSaveActionButton)}
|
||||
aria-label="Save title"
|
||||
testId="dashboard-title-save"
|
||||
onClick={onCommit}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
className={styles.titleEditActionButton}
|
||||
aria-label="Cancel title edit"
|
||||
testId="dashboard-title-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<TooltipSimple title={title.length > 30 ? title : ''}>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.clickableTitle]: canEdit,
|
||||
})}
|
||||
data-testid="dashboard-title"
|
||||
onClick={canEdit ? onStartEdit : undefined}
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{isPublicDashboard && (
|
||||
<TooltipSimple title="This dashboard is publicly accessible">
|
||||
<Globe size={14} className={styles.publicDashboardIcon} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{isDashboardLocked && (
|
||||
<TooltipSimple title="This dashboard is locked">
|
||||
<LockKeyhole size={14} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardTitle;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { Card } from 'antd';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
@@ -12,31 +13,34 @@ import type {
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
import DashboardInfo from './DashboardInfo/DashboardInfo';
|
||||
import { useEditableTitle } from './DashboardInfo/useEditableTitle';
|
||||
import DashboardMeta from './DashboardMeta/DashboardMeta';
|
||||
import DashboardTitle from './DashboardTitle/DashboardTitle';
|
||||
import { useEditableTitle } from './DashboardTitle/useEditableTitle';
|
||||
|
||||
import styles from './DashboardPageToolbar.module.scss';
|
||||
import styles from './DashboardDescription.module.scss';
|
||||
|
||||
interface DashboardPageToolbarProps {
|
||||
interface DashboardDescriptionProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
handle: FullScreenHandle;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
const { dashboard, handle, refetch } = props;
|
||||
|
||||
const id = dashboard.id;
|
||||
const isDashboardLocked = !!dashboard.locked;
|
||||
|
||||
const title = dashboard.spec.display.name;
|
||||
const description = dashboard.spec.display.description ?? '';
|
||||
const title = dashboard.spec?.display?.name ?? '';
|
||||
const description = dashboard.spec?.display?.description ?? '';
|
||||
const image = dashboard.image || Base64Icons[0];
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
@@ -47,6 +51,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
);
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const setIsPanelTypeSelectionModalOpen = usePanelTypeSelectionModalStore(
|
||||
(s) => s.setIsPanelTypeSelectionModalOpen,
|
||||
@@ -54,6 +59,9 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
const addPanelPermission = !isDashboardLocked;
|
||||
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
|
||||
const isPublicDashboard = false;
|
||||
|
||||
const handleLockDashboardToggle = useCallback(async (): Promise<void> => {
|
||||
if (!id) {
|
||||
@@ -102,7 +110,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
onSave: onNameSave,
|
||||
});
|
||||
|
||||
const onAddPanel = useCallback((): void => {
|
||||
const onEmptyWidgetHandler = useCallback((): void => {
|
||||
void logEvent('Dashboard Detail V2: Add new panel clicked', {
|
||||
dashboardId: id,
|
||||
});
|
||||
@@ -110,15 +118,15 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
}, [id, setIsPanelTypeSelectionModalOpen]);
|
||||
|
||||
return (
|
||||
<section className={styles.dashboardPageToolbarContainer}>
|
||||
<div className={styles.dashboardInfoWithActions}>
|
||||
<DashboardInfo
|
||||
<Card className={styles.dashboardDescriptionContainer}>
|
||||
<DashboardHeader title={title} image={image} />
|
||||
<section className={styles.dashboardDetails}>
|
||||
<DashboardTitle
|
||||
title={title}
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={false}
|
||||
isPublicDashboard={isPublicDashboard}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
isEditable={editDashboard}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
@@ -127,18 +135,20 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
onCancel={cancel}
|
||||
/>
|
||||
<DashboardActions
|
||||
title={title}
|
||||
dashboard={dashboard}
|
||||
handle={handle}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
editDashboard={editDashboard}
|
||||
isAuthor={isAuthor}
|
||||
onAddPanel={onAddPanel}
|
||||
addPanelPermission={addPanelPermission}
|
||||
onAddPanel={onEmptyWidgetHandler}
|
||||
onLockToggle={handleLockDashboardToggle}
|
||||
onOpenRename={startEdit}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
<DashboardMeta tags={tags} description={description} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPageToolbar;
|
||||
export default DashboardDescription;
|
||||
@@ -1,11 +0,0 @@
|
||||
.dashboardActionsContainer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboardActionsSecondary {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
.dashboardInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 40%;
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
width: 30%;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardTitleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboardImage {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dashboardTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: fit-content;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboardTitleHover {
|
||||
cursor: text !important;
|
||||
}
|
||||
|
||||
.dashboardTitleEditor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboardTitleInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboardTitleActionButton {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dashboardTags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { KeyboardEvent } from 'react';
|
||||
import { Check, Globe, LockKeyhole, X } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
interface DashboardInfoProps {
|
||||
title: string;
|
||||
image: string;
|
||||
tags: string[];
|
||||
description: string;
|
||||
isPublicDashboard: boolean;
|
||||
isDashboardLocked: boolean;
|
||||
isEditing: boolean;
|
||||
draft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
onStartEdit: () => void;
|
||||
onCommit: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function DashboardInfo({
|
||||
title,
|
||||
image,
|
||||
tags,
|
||||
description,
|
||||
isPublicDashboard,
|
||||
isDashboardLocked,
|
||||
isEditing,
|
||||
draft,
|
||||
onDraftChange,
|
||||
onStartEdit,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: DashboardInfoProps): JSX.Element {
|
||||
const canEdit = useDashboardStore((s) => s.isEditable);
|
||||
|
||||
const hasTags = tags.length > 0;
|
||||
const hasDescription = !isEmpty(description);
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onCommit();
|
||||
} else if (event.key === 'Escape') {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardInfo}>
|
||||
<div className={styles.dashboardTitleContainer}>
|
||||
<img src={image} alt={title} className={styles.dashboardImage} />
|
||||
{isEditing ? (
|
||||
<div className={styles.dashboardTitleEditor}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={draft}
|
||||
testId="dashboard-title-input"
|
||||
maxLength={120}
|
||||
className={styles.dashboardTitleInput}
|
||||
onChange={(e): void => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="icon"
|
||||
className={styles.dashboardTitleActionButton}
|
||||
aria-label="Save title"
|
||||
testId="dashboard-title-save"
|
||||
onClick={onCommit}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.dashboardTitleActionButton}
|
||||
aria-label="Cancel title edit"
|
||||
testId="dashboard-title-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<TooltipSimple title={title}>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.dashboardTitleHover]: canEdit,
|
||||
})}
|
||||
data-testid="dashboard-title"
|
||||
onClick={canEdit ? onStartEdit : undefined}
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{isPublicDashboard && (
|
||||
<TooltipSimple title="This dashboard is publicly accessible">
|
||||
<Globe size={14} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{isDashboardLocked && (
|
||||
<TooltipSimple title="This dashboard is locked">
|
||||
<LockKeyhole size={14} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasTags && (
|
||||
<div className={styles.dashboardTags}>
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag} color="warning" variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDescription && (
|
||||
<Typography.Text color="muted">{description}</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardInfo;
|
||||
@@ -1,20 +0,0 @@
|
||||
.dashboardPageToolbarContainer {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
color: var(--l2-foreground);
|
||||
background-color: var(--l1-background);
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 2px 0px var(--l2-border);
|
||||
}
|
||||
|
||||
.dashboardPageToolbarSubContainer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboardInfoWithActions {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,8 +1,3 @@
|
||||
.tabsContent {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -14,10 +9,3 @@
|
||||
line-height: 1;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
// shared "settings card" wrapper, used by the dashboard-info form and cross-panel sync
|
||||
.settingsCard {
|
||||
padding: 24px 16px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Radio to @signozhq/ui/radio-group
|
||||
import { Col, Radio, Tooltip } from 'antd';
|
||||
import { ExternalLink, SolidInfoCircle } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
@@ -12,9 +13,7 @@ import {
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
import cx from 'classnames';
|
||||
|
||||
import SegmentedControl from '../SegmentedControl/SegmentedControl';
|
||||
import settingsStyles from '../../DashboardSettings.module.scss';
|
||||
import styles from './CrossPanelSync.module.scss';
|
||||
import styles from '../GeneralSettings.module.scss';
|
||||
|
||||
interface CrossPanelSyncProps {
|
||||
dashboardId: string;
|
||||
@@ -27,15 +26,12 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
|
||||
useSyncTooltipFilterMode(dashboardId);
|
||||
|
||||
return (
|
||||
<div className={cx(settingsStyles.settingsCard, styles.crossPanelSyncGroup)}>
|
||||
<Col className={cx(styles.overviewSettings, styles.crossPanelSyncGroup)}>
|
||||
<div className={styles.crossPanelSyncSectionHeader}>
|
||||
<Typography.Text className={styles.crossPanelsSyncSectionTitle}>
|
||||
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
|
||||
Cross-Panel Sync
|
||||
</Typography.Text>
|
||||
|
||||
<TooltipSimple
|
||||
side="top"
|
||||
withPortal={false}
|
||||
<Tooltip
|
||||
title={
|
||||
<div className={styles.crossPanelSyncTooltipContent}>
|
||||
<strong className={styles.crossPanelSyncTooltipTitle}>
|
||||
@@ -44,7 +40,7 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
|
||||
<span className={styles.crossPanelSyncTooltipDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</span>
|
||||
<Typography.Link
|
||||
<a
|
||||
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -52,14 +48,15 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink size={12} />
|
||||
</Typography.Link>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
|
||||
</TooltipSimple>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
@@ -69,18 +66,19 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
testId="cursor-sync-mode"
|
||||
<Radio.Group
|
||||
value={cursorSyncMode}
|
||||
onChange={setCursorSyncMode}
|
||||
options={[
|
||||
{ label: 'No Sync', value: DashboardCursorSync.None },
|
||||
{ label: 'Crosshair', value: DashboardCursorSync.Crosshair },
|
||||
{ label: 'Tooltip', value: DashboardCursorSync.Tooltip },
|
||||
]}
|
||||
/>
|
||||
onChange={(e): void => {
|
||||
setCursorSyncMode(e.target.value as DashboardCursorSync);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Crosshair}>
|
||||
Crosshair
|
||||
</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
|
||||
{cursorSyncMode === DashboardCursorSync.Tooltip && (
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
@@ -92,25 +90,24 @@ function CrossPanelSync({ dashboardId }: CrossPanelSyncProps): JSX.Element {
|
||||
matching ones highlighted
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<SegmentedControl
|
||||
testId="sync-tooltip-filter-mode"
|
||||
<Radio.Group
|
||||
value={syncTooltipFilterMode}
|
||||
onChange={(value): void => {
|
||||
onChange={(e): void => {
|
||||
void logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
mode: value,
|
||||
mode: e.target.value,
|
||||
});
|
||||
setSyncTooltipFilterMode(value);
|
||||
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
|
||||
}}
|
||||
options={[
|
||||
{ label: 'All', value: SyncTooltipFilterMode.All },
|
||||
{ label: 'Filtered', value: SyncTooltipFilterMode.Filtered },
|
||||
]}
|
||||
/>
|
||||
>
|
||||
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
|
||||
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
|
||||
Filtered
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Select/Input to @signozhq/ui
|
||||
import { Col, Input, Select, Space } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
|
||||
|
||||
import { Base64Icons } from '../utils';
|
||||
import styles from '../GeneralSettings.module.scss';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface GeneralFormProps {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
tags: string[];
|
||||
onTitleChange: (value: string) => void;
|
||||
onDescriptionChange: (value: string) => void;
|
||||
onImageChange: (value: string) => void;
|
||||
onTagsChange: Dispatch<SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
function GeneralForm({
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
tags,
|
||||
onTitleChange,
|
||||
onDescriptionChange,
|
||||
onImageChange,
|
||||
onTagsChange,
|
||||
}: GeneralFormProps): JSX.Element {
|
||||
return (
|
||||
<Col className={styles.overviewSettings}>
|
||||
<Space direction="vertical" className={styles.formSpace}>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
|
||||
<section className={styles.nameIconInput}>
|
||||
<Select
|
||||
defaultActiveFirstOption
|
||||
data-testid="dashboard-image"
|
||||
suffixIcon={null}
|
||||
rootClassName={styles.dashboardImageInput}
|
||||
value={image}
|
||||
onChange={onImageChange}
|
||||
>
|
||||
{Base64Icons.map((icon) => (
|
||||
<Option value={icon} key={icon}>
|
||||
<img
|
||||
src={icon}
|
||||
alt="dashboard-icon"
|
||||
className={styles.listItemImage}
|
||||
/>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className={styles.dashboardNameInput}
|
||||
value={title}
|
||||
onChange={(e): void => onTitleChange(e.target.value)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Description</Typography>
|
||||
<Input.TextArea
|
||||
data-testid="dashboard-desc"
|
||||
rows={6}
|
||||
value={description}
|
||||
className={styles.descriptionTextArea}
|
||||
onChange={(e): void => onDescriptionChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Tags</Typography>
|
||||
<AddTags tags={tags} setTags={onTagsChange} />
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
export default GeneralForm;
|
||||
@@ -0,0 +1,238 @@
|
||||
.overviewContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.overviewSettings {
|
||||
padding: 16px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.crossPanelSyncGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.formSpace {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 21px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.crossPanelSyncInfoIcon {
|
||||
cursor: help;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipTitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDescription {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDocLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--primary-background);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
& + & {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.crossPanelSyncInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncDescription {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.nameIconInput {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dashboardImageInput {
|
||||
:global(.ant-select-selector) {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l3-background) !important;
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&:global(.ant-select-dropdown) {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
:global(.ant-select-item) {
|
||||
padding: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
:global(.ant-select-item-option-content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItemImage {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardNameInput {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.dashboardName {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.descriptionTextArea {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.overviewSettingsFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: -webkit-fill-available;
|
||||
padding: 12px 16px 12px 0px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.unsavedDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--primary-background);
|
||||
box-shadow: 0px 0px 6px 0px
|
||||
color-mix(in srgb, var(--primary-background) 40%, transparent);
|
||||
}
|
||||
|
||||
.unsavedChanges {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.footerActionBtns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.discardBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0px !important;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './UnsavedChangesFooter.module.scss';
|
||||
import styles from '../GeneralSettings.module.scss';
|
||||
|
||||
interface UnsavedChangesFooterProps {
|
||||
count: number;
|
||||
@@ -29,13 +29,13 @@ function UnsavedChangesFooter({
|
||||
{count > 1 && 's'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.footerActionButtons}>
|
||||
<div className={styles.footerActionBtns}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
disabled={isSaving}
|
||||
prefix={<X size={14} />}
|
||||
onClick={onDiscard}
|
||||
className={styles.discardBtn}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
@@ -47,6 +47,7 @@ function UnsavedChangesFooter({
|
||||
prefix={<Check size={14} />}
|
||||
testId="save-dashboard-config"
|
||||
onClick={onSave}
|
||||
className={styles.saveBtn}
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
@@ -11,22 +11,22 @@ import APIError from 'types/api/error';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import CrossPanelSync from './CrossPanelSync/CrossPanelSync';
|
||||
import DashboardInfoForm from './DashboardInfoForm/DashboardInfoForm';
|
||||
import GeneralForm from './GeneralForm/GeneralForm';
|
||||
import UnsavedChangesFooter from './UnsavedChangesFooter/UnsavedChangesFooter';
|
||||
import { Base64Icons, stringsToTags, tagsToStrings } from './utils';
|
||||
import styles from './Overview.module.scss';
|
||||
import styles from './GeneralSettings.module.scss';
|
||||
|
||||
interface OverviewProps {
|
||||
interface GeneralSettingsProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
function GeneralSettings({ dashboard }: GeneralSettingsProps): JSX.Element {
|
||||
const id = dashboard.id;
|
||||
|
||||
const refetch = useDashboardStore((s) => s.refetch);
|
||||
|
||||
const title = dashboard.spec.display.name;
|
||||
const description = dashboard.spec.display.description ?? '';
|
||||
const title = dashboard.spec?.display?.name ?? '';
|
||||
const description = dashboard.spec?.display?.description ?? '';
|
||||
const image = dashboard.image || Base64Icons[0];
|
||||
const tagsAsStrings = useMemo(
|
||||
() => tagsToStrings(dashboard.tags ?? []),
|
||||
@@ -64,7 +64,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
value,
|
||||
});
|
||||
|
||||
if (updatedTitle !== title && updatedTitle !== '') {
|
||||
if (updatedTitle !== title) {
|
||||
ops.push(replace('/spec/display/name', updatedTitle));
|
||||
}
|
||||
if (updatedDescription !== description) {
|
||||
@@ -89,6 +89,9 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
]);
|
||||
|
||||
const onSaveHandler = useCallback(async (): Promise<void> => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const ops = buildPatch();
|
||||
if (ops.length === 0) {
|
||||
return;
|
||||
@@ -107,7 +110,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
}, [id, buildPatch, refetch, showErrorModal]);
|
||||
|
||||
useEffect(() => {
|
||||
let numberOfUnsavedChanges = 0;
|
||||
let n = 0;
|
||||
const initialValues = [title, description, tagsAsStrings, image];
|
||||
const updatedValues = [
|
||||
updatedTitle,
|
||||
@@ -117,10 +120,10 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
];
|
||||
initialValues.forEach((val, index) => {
|
||||
if (!isEqual(val, updatedValues[index])) {
|
||||
numberOfUnsavedChanges += 1;
|
||||
n += 1;
|
||||
}
|
||||
});
|
||||
setNumberOfUnsavedChanges(numberOfUnsavedChanges);
|
||||
setNumberOfUnsavedChanges(n);
|
||||
}, [
|
||||
description,
|
||||
image,
|
||||
@@ -141,7 +144,7 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
|
||||
return (
|
||||
<div className={styles.overviewContent}>
|
||||
<DashboardInfoForm
|
||||
<GeneralForm
|
||||
title={updatedTitle}
|
||||
description={updatedDescription}
|
||||
image={updatedImage}
|
||||
@@ -164,4 +167,4 @@ function Overview({ dashboard }: OverviewProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
export default Overview;
|
||||
export default GeneralSettings;
|
||||
@@ -1,86 +0,0 @@
|
||||
.crossPanelSyncGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.crossPanelsSyncSectionTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.crossPanelSyncInfoIcon {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipTitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDescription {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDocLink {
|
||||
color: var(--primary-background);
|
||||
font-size: 12px;
|
||||
margin-top: 16px;
|
||||
vertical-align: middle;
|
||||
|
||||
// typography override
|
||||
--typography-text-display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.crossPanelSyncRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
& + & {
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed var(--l2-border);
|
||||
}
|
||||
}
|
||||
|
||||
.crossPanelSyncInfo {
|
||||
display: flex;
|
||||
flex: 1 1 80px;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncDescription {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
.formSpace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.infoItemContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.infoTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.nameIconInput {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
[data-radix-popper-content-wrapper] {
|
||||
z-index: 1100 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardImageInput {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--l3-background);
|
||||
|
||||
// icon-only trigger: drop the dropdown chevron, keep just the selected icon
|
||||
svg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboardImageOptions {
|
||||
min-width: min-content;
|
||||
}
|
||||
|
||||
.dashboardImageSelectItem {
|
||||
width: min-content;
|
||||
|
||||
span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.listItemImage {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardNameInput {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.descriptionTextArea {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
// the V1 tags input ships borderless; give the field a visible box to match
|
||||
.tagsField {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
// background: var(--l3-background);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { Dispatch, SetStateAction } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
|
||||
import { Input as AntdInput } from 'antd';
|
||||
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
|
||||
|
||||
import { Base64Icons } from '../utils';
|
||||
import settingsStyles from '../../DashboardSettings.module.scss';
|
||||
import styles from './DashboardInfoForm.module.scss';
|
||||
|
||||
interface DashboardInfoFormProps {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
tags: string[];
|
||||
onTitleChange: (value: string) => void;
|
||||
onDescriptionChange: (value: string) => void;
|
||||
onImageChange: (value: string) => void;
|
||||
onTagsChange: Dispatch<SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
function DashboardInfoForm({
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
tags,
|
||||
onTitleChange,
|
||||
onDescriptionChange,
|
||||
onImageChange,
|
||||
onTagsChange,
|
||||
}: DashboardInfoFormProps): JSX.Element {
|
||||
return (
|
||||
<div className={settingsStyles.settingsCard}>
|
||||
<div className={styles.formSpace}>
|
||||
<div className={styles.infoItemContainer}>
|
||||
<Typography className={styles.infoTitle}>Dashboard Name</Typography>
|
||||
<section className={styles.nameIconInput}>
|
||||
<Select
|
||||
value={image}
|
||||
onChange={(value): void => onImageChange(value as string)}
|
||||
>
|
||||
<SelectTrigger className={styles.dashboardImageInput} />
|
||||
<SelectContent
|
||||
className={styles.dashboardImageOptions}
|
||||
withPortal={false}
|
||||
>
|
||||
{Base64Icons.map((icon) => (
|
||||
<SelectItem
|
||||
key={icon}
|
||||
value={icon}
|
||||
className={styles.dashboardImageSelectItem}
|
||||
>
|
||||
<img
|
||||
src={icon}
|
||||
alt="dashboard-icon"
|
||||
className={styles.listItemImage}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
testId="dashboard-name"
|
||||
className={styles.dashboardNameInput}
|
||||
value={title}
|
||||
onChange={(e): void => onTitleChange(e.target.value)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className={styles.infoItemContainer}>
|
||||
<Typography className={styles.infoTitle}>Description</Typography>
|
||||
<AntdInput.TextArea
|
||||
data-testid="dashboard-desc"
|
||||
rows={6}
|
||||
value={description}
|
||||
className={styles.descriptionTextArea}
|
||||
onChange={(e): void => onDescriptionChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.infoItemContainer}>
|
||||
<Typography className={styles.infoTitle}>Tags</Typography>
|
||||
<div className={styles.tagsField}>
|
||||
<AddTags tags={tags} setTags={onTagsChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardInfoForm;
|
||||
@@ -1,5 +0,0 @@
|
||||
.overviewContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
.segmented {
|
||||
// override RadioGroup's default vertical grid; lay segments out connected
|
||||
display: inline-flex;
|
||||
grid-auto-flow: column;
|
||||
gap: 0;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.segment {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
// the visible segment is the radio's label (htmlFor-wired, so clicks register)
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 6px 14px;
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
// collapse the radio circle into a transparent full-cell click target
|
||||
.segmentInput {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
// hide the default radio dot/indicator
|
||||
* {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// highlight the selected segment as a raised, lighter pill (data-state is a
|
||||
// stable Radix attribute). --l3-background is the lightest layer, so lift it
|
||||
// further with a subtle foreground tint rather than going darker.
|
||||
.segmentInput[data-state='checked'] + label {
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
|
||||
import styles from './SegmentedControl.module.scss';
|
||||
|
||||
export interface SegmentedControlOption<T extends string> {
|
||||
label: string;
|
||||
value: T;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps<T extends string> {
|
||||
value: T;
|
||||
options: SegmentedControlOption<T>[];
|
||||
onChange: (value: T) => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connected pill segmented control composed on top of @signozhq/ui RadioGroup:
|
||||
* the radio circle is collapsed into a transparent full-cell click target and
|
||||
* the label becomes the visible segment (highlighted via the radio's stable
|
||||
* `data-state="checked"`). Keeps radio semantics + keyboard nav.
|
||||
*/
|
||||
function SegmentedControl<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
testId,
|
||||
}: SegmentedControlProps<T>): JSX.Element {
|
||||
return (
|
||||
<RadioGroup
|
||||
className={styles.segmented}
|
||||
value={value}
|
||||
onChange={(next): void => onChange(next as T)}
|
||||
testId={testId}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<RadioGroupItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
containerClassName={styles.segment}
|
||||
className={styles.segmentInput}
|
||||
testId={testId ? `${testId}-${option.value}` : undefined}
|
||||
>
|
||||
{option.label}
|
||||
</RadioGroupItem>
|
||||
))}
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default SegmentedControl;
|
||||
@@ -1,39 +0,0 @@
|
||||
.overviewSettingsFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: -webkit-fill-available;
|
||||
padding: 12px 16px 12px 16px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.unsavedDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--primary-background);
|
||||
box-shadow: 0px 0px 6px 0px
|
||||
color-mix(in srgb, var(--primary-background) 40%, transparent);
|
||||
}
|
||||
|
||||
.unsavedChanges {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.footerActionButtons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,92 +1,45 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Braces, Globe, Table } from '@signozhq/icons';
|
||||
import {
|
||||
TabItemProps,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsRoot,
|
||||
TabsTrigger,
|
||||
} from '@signozhq/ui/tabs';
|
||||
import { TabItemProps, Tabs } from '@signozhq/ui/tabs';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import Overview from './Overview';
|
||||
import GeneralSettings from './General';
|
||||
import { SettingsTabPlaceholder } from './utils';
|
||||
import VariablesSettings from './Variables';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import styles from './DashboardSettings.module.scss';
|
||||
|
||||
interface DashboardSettingsProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
enum TabKeys {
|
||||
OVERVIEW = 'Overview',
|
||||
VARIABLES = 'Variables',
|
||||
PUBLISH = 'Publish',
|
||||
}
|
||||
|
||||
const prefixIcons: Record<TabKeys, JSX.Element> = {
|
||||
[TabKeys.OVERVIEW]: <Table size={14} />,
|
||||
[TabKeys.VARIABLES]: <Braces size={14} />,
|
||||
[TabKeys.PUBLISH]: <Globe size={14} />,
|
||||
};
|
||||
|
||||
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
|
||||
const enablePublicDashboard = isCloudUser || isEnterpriseSelfHostedUser;
|
||||
|
||||
const items: TabItemProps[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: TabKeys.OVERVIEW,
|
||||
label: TabKeys.OVERVIEW,
|
||||
children: <Overview dashboard={dashboard} />,
|
||||
key: 'general',
|
||||
label: 'General',
|
||||
children: <GeneralSettings dashboard={dashboard} />,
|
||||
prefixIcon: <Table size={14} />,
|
||||
},
|
||||
{
|
||||
key: TabKeys.VARIABLES,
|
||||
label: TabKeys.VARIABLES,
|
||||
key: 'variables',
|
||||
label: 'Variables',
|
||||
children: <VariablesSettings dashboard={dashboard} />,
|
||||
prefixIcon: <Braces size={14} />,
|
||||
},
|
||||
{
|
||||
key: 'public-dashboard',
|
||||
label: 'Publish',
|
||||
children: (
|
||||
<SettingsTabPlaceholder message="V2 public dashboard publishing coming next." />
|
||||
),
|
||||
prefixIcon: <Globe size={14} />,
|
||||
},
|
||||
...(enablePublicDashboard
|
||||
? [
|
||||
{
|
||||
key: TabKeys.PUBLISH,
|
||||
label: TabKeys.PUBLISH,
|
||||
children: (
|
||||
<SettingsTabPlaceholder message="V2 public dashboard publishing coming next." />
|
||||
),
|
||||
disabled: user?.role !== USER_ROLES.ADMIN,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[enablePublicDashboard, dashboard, user?.role],
|
||||
[dashboard],
|
||||
);
|
||||
|
||||
return (
|
||||
<TabsRoot defaultValue={TabKeys.OVERVIEW}>
|
||||
<TabsList variant="primary">
|
||||
{Object.values(TabKeys).map((key) => (
|
||||
<TabsTrigger value={key} key={key}>
|
||||
{prefixIcons[key]}
|
||||
{key}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{items.map((item) => (
|
||||
<TabsContent value={item.key} key={item.key} className={styles.tabsContent}>
|
||||
{item.children}
|
||||
</TabsContent>
|
||||
))}
|
||||
</TabsRoot>
|
||||
);
|
||||
return <Tabs defaultValue="general" items={items} />;
|
||||
}
|
||||
|
||||
export default DashboardSettings;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
.dashboardBreadcrumbs {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
max-width: 80%;
|
||||
padding-left: 8px;
|
||||
|
||||
.linkToPreviousPage {
|
||||
// Collapse the design-system Button's fixed-height/padding box so it hugs
|
||||
// the label like inline text (the breadcrumb is text, not a chunky button).
|
||||
--button-height: auto;
|
||||
--button-padding: 0;
|
||||
--button-gap: 4px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.currentPage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0px 2px;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
height: 20px;
|
||||
|
||||
max-width: calc(100% - 120px);
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.currentPage:hover {
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
|
||||
color: var(--bg-robin-300);
|
||||
}
|
||||
|
||||
.dashboardIconImage {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback } from 'react';
|
||||
import { LayoutGrid } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import getSessionStorageApi from 'api/browser/sessionstorage/get';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import styles from './DashboardBreadcrumbs.module.scss';
|
||||
|
||||
interface DashboardBreadcrumbsProps {
|
||||
title: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
function DashboardBreadcrumbs({
|
||||
title,
|
||||
image,
|
||||
}: DashboardBreadcrumbsProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const goToListPage = useCallback(() => {
|
||||
const dashboardsListQueryParamsString = getSessionStorageApi(
|
||||
DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY,
|
||||
);
|
||||
|
||||
if (dashboardsListQueryParamsString) {
|
||||
safeNavigate({
|
||||
pathname: ROUTES.ALL_DASHBOARD,
|
||||
search: `?${dashboardsListQueryParamsString}`,
|
||||
});
|
||||
} else {
|
||||
safeNavigate(ROUTES.ALL_DASHBOARD);
|
||||
}
|
||||
}, [safeNavigate]);
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardBreadcrumbs}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<LayoutGrid size={14} />}
|
||||
onClick={goToListPage}
|
||||
className={styles.linkToPreviousPage}
|
||||
testId="dashboard-breadcrumb-list"
|
||||
>
|
||||
Dashboard
|
||||
</Button>
|
||||
<div>/</div>
|
||||
<div className={styles.currentPage}>
|
||||
<img
|
||||
src={image}
|
||||
alt="dashboard-icon"
|
||||
className={styles.dashboardIconImage}
|
||||
/>
|
||||
<Typography.Text>{title}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardBreadcrumbs;
|
||||
@@ -0,0 +1,9 @@
|
||||
.dashboardHeader {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { memo } from 'react';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
|
||||
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
|
||||
|
||||
import styles from './DashboardHeader.module.scss';
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
title: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
function DashboardHeader({ title, image }: DashboardHeaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.dashboardHeader}>
|
||||
<DashboardBreadcrumbs title={title} image={image} />
|
||||
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DashboardHeader);
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { LayoutGrid } from '@signozhq/icons';
|
||||
import getSessionStorageApi from 'api/browser/sessionstorage/get';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
} from '@signozhq/ui/breadcrumb';
|
||||
|
||||
interface DashboardPageBreadcrumbsProps {
|
||||
title: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
function DashboardPageBreadcrumbs({
|
||||
title,
|
||||
image,
|
||||
}: DashboardPageBreadcrumbsProps): JSX.Element {
|
||||
const dashboardPageLink = useMemo(() => {
|
||||
const dashboardsListQueryParamsString = getSessionStorageApi(
|
||||
DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY,
|
||||
);
|
||||
|
||||
return dashboardsListQueryParamsString
|
||||
? `${ROUTES.ALL_DASHBOARD}?${dashboardsListQueryParamsString}`
|
||||
: ROUTES.ALL_DASHBOARD;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink icon={<LayoutGrid size={14} />} href={dashboardPageLink}>
|
||||
Dashboard
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator>/</BreadcrumbSeparator>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink icon={<img src={image} alt="dashboard-icon" />}>
|
||||
{title}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPageBreadcrumbs;
|
||||
@@ -1,9 +0,0 @@
|
||||
.dashboardPageHeader {
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-left: 14px;
|
||||
height: 48px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
|
||||
import DashboardPageBreadcrumbs from './DashboardPageBreadcrumbs';
|
||||
|
||||
import styles from './DashboardPageHeader.module.scss';
|
||||
|
||||
interface DashboardPageHeaderProps {
|
||||
title: string;
|
||||
image: string;
|
||||
}
|
||||
|
||||
function DashboardPageHeader({
|
||||
title,
|
||||
image,
|
||||
}: DashboardPageHeaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.dashboardPageHeader}>
|
||||
<DashboardPageBreadcrumbs title={title} image={image} />
|
||||
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DashboardPageHeader);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
|
||||
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -6,12 +6,10 @@ import PanelTypeSelectionModal from 'container/DashboardContainer/PanelTypeSelec
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import DashboardPageToolbar from './DashboardPageToolbar';
|
||||
import DashboardDescription from './DashboardDescription';
|
||||
import PanelsAndSectionsLayout from './PanelsAndSectionsLayout';
|
||||
import { useDashboardStore } from './store/useDashboardStore';
|
||||
import styles from './DashboardContainer.module.scss';
|
||||
import DashboardPageHeader from './components/DashboardPageHeader/DashboardPageHeader';
|
||||
import { Base64Icons } from './DashboardSettings/Overview/utils';
|
||||
|
||||
interface DashboardContainerProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
@@ -22,49 +20,32 @@ function DashboardContainer({
|
||||
dashboard,
|
||||
refetch,
|
||||
}: DashboardContainerProps): JSX.Element {
|
||||
useEffect(() => {
|
||||
document.title = dashboard.name;
|
||||
}, [dashboard.name]);
|
||||
|
||||
const fullScreenHandle = useFullScreenHandle();
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboardPermission] = useComponentPermission(
|
||||
['edit_dashboard'],
|
||||
user.role,
|
||||
);
|
||||
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
|
||||
const isEditable = !dashboard.locked && editDashboard;
|
||||
|
||||
// Publish edit context to the store so hooks/components read it from there
|
||||
// instead of receiving dashboardId/isEditable/refetch as props down the tree.
|
||||
const setEditContext = useDashboardStore((s) => s.setEditContext);
|
||||
useEffect(() => {
|
||||
setEditContext({
|
||||
dashboardId: dashboard.id,
|
||||
isEditable: !dashboard.locked && editDashboardPermission,
|
||||
refetch,
|
||||
});
|
||||
}, [
|
||||
dashboard.id,
|
||||
dashboard.locked,
|
||||
editDashboardPermission,
|
||||
refetch,
|
||||
setEditContext,
|
||||
]);
|
||||
setEditContext({ dashboardId: dashboard.id ?? '', isEditable, refetch });
|
||||
}, [dashboard.id, isEditable, refetch, setEditContext]);
|
||||
|
||||
const spec = dashboard.spec;
|
||||
const image = dashboard.image || Base64Icons[0];
|
||||
const name = spec.display.name;
|
||||
const { spec } = dashboard;
|
||||
const layouts = useMemo(() => spec?.layouts ?? [], [spec?.layouts]);
|
||||
const panels = useMemo(() => spec?.panels ?? {}, [spec?.panels]);
|
||||
|
||||
return (
|
||||
<FullScreen handle={fullScreenHandle}>
|
||||
<div className={styles.container}>
|
||||
<DashboardPageHeader title={name} image={image} />
|
||||
<DashboardPageToolbar
|
||||
<DashboardDescription
|
||||
dashboard={dashboard}
|
||||
handle={fullScreenHandle}
|
||||
refetch={refetch}
|
||||
/>
|
||||
<PanelsAndSectionsLayout layouts={spec.layouts} panels={spec.panels} />
|
||||
<PanelsAndSectionsLayout layouts={layouts} panels={panels} />
|
||||
</div>
|
||||
{/* Shared panel-type picker (V1 component): opened from any "New Panel"
|
||||
trigger; navigates to the widget editor route on selection. */}
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { GridItem } from './utils';
|
||||
* intentionally side-effect-free (no React, no network) so they can be unit
|
||||
* tested and reused by the layout hooks. JSON pointers target the postable
|
||||
* shape: `/spec/layouts/...`, `/spec/panels/...` (matches the existing V2
|
||||
* patches in DashboardSettings/Overview and DashboardDescription).
|
||||
* patches in DashboardSettings/General and DashboardDescription).
|
||||
*/
|
||||
|
||||
const { add, replace, remove } = DashboardtypesPatchOpDTO;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -15,6 +16,13 @@ function DashboardPageV2(): JSX.Element {
|
||||
});
|
||||
|
||||
const dashboard = data?.data;
|
||||
const name = dashboard?.spec?.display?.name;
|
||||
|
||||
useEffect(() => {
|
||||
if (name) {
|
||||
document.title = name;
|
||||
}
|
||||
}, [name]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Spinner tip="Loading dashboard..." />;
|
||||
|
||||
67
pkg/types/dashboardtypes/perses_v1_to_v2.go
Normal file
67
pkg/types/dashboardtypes/perses_v1_to_v2.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
// V1 → V2 migration. The v1 storable shape is the frontend's `DashboardData`
|
||||
// (see frontend/src/types/api/dashboard/getAll.ts); v2 is DashboardV2 /
|
||||
// DashboardSpec.
|
||||
//
|
||||
// Assumes the v1 widget query data has already been migrated to v5 shape
|
||||
// (transition.dashboardMigrateV5). Pre-v5 builder queries will produce
|
||||
// invalid v2 envelopes — run the v4→v5 migration first.
|
||||
//
|
||||
// The conversion is split across sibling files by concern:
|
||||
// - perses_v1_to_v2_tags.go tags
|
||||
// - perses_v1_to_v2_panels.go widgets → panels (+ panel field mappers)
|
||||
// - perses_v1_to_v2_queries.go widget queries
|
||||
// - perses_v1_to_v2_layouts.go grid layouts and sections
|
||||
// - perses_v1_to_v2_variables.go variables
|
||||
// - perses_v1_to_v2_helpers.go generic map/slice accessors
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Entry point
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func (storable StorableDashboard) IsV2() bool {
|
||||
metadata, _ := storable.Data["metadata"].(map[string]any)
|
||||
if metadata == nil {
|
||||
return false
|
||||
}
|
||||
version, _ := metadata["schemaVersion"].(string)
|
||||
return version == SchemaVersion
|
||||
}
|
||||
|
||||
func (storable StorableDashboard) ConvertV1ToV2() (*DashboardV2, error) {
|
||||
if storable.IsV2() {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidData, "dashboard %s is already in %s schema", storable.ID, SchemaVersion)
|
||||
}
|
||||
|
||||
image, _ := storable.Data["image"].(string)
|
||||
title, _ := storable.Data["title"].(string)
|
||||
description, _ := storable.Data["description"].(string)
|
||||
|
||||
spec := DashboardSpec{
|
||||
Display: Display{Name: title, Description: description},
|
||||
Variables: convertV1Variables(storable.Data["variables"]),
|
||||
Panels: convertV1Panels(storable.Data["widgets"]),
|
||||
Layouts: convertV1Layouts(storable.Data),
|
||||
}
|
||||
|
||||
return &DashboardV2{
|
||||
Identifiable: storable.Identifiable,
|
||||
TimeAuditable: storable.TimeAuditable,
|
||||
UserAuditable: storable.UserAuditable,
|
||||
OrgID: storable.OrgID,
|
||||
Locked: storable.Locked,
|
||||
Source: storable.Source,
|
||||
DashboardV2MetadataBase: DashboardV2MetadataBase{
|
||||
SchemaVersion: SchemaVersion,
|
||||
Image: image,
|
||||
},
|
||||
Name: storable.Name,
|
||||
Tags: convertV1TagsForOrg(storable.OrgID, storable.Data["tags"]),
|
||||
Spec: spec,
|
||||
}, nil
|
||||
}
|
||||
85
pkg/types/dashboardtypes/perses_v1_to_v2_helpers.go
Normal file
85
pkg/types/dashboardtypes/perses_v1_to_v2_helpers.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package dashboardtypes
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Generic helpers
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// ptrValueAt is the pointer-returning sibling of valueAt: it returns *T so the
|
||||
// caller can tell "absent / wrong type" (nil) apart from a present zero value.
|
||||
// Used for optional fields like soft axis bounds and histogram bucket sizing.
|
||||
func ptrValueAt[T any](raw any, key string) *T {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
v, ok := m[key].(T)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
func readStringMap(raw any) map[string]string {
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok || len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
if s, ok := v.(string); ok {
|
||||
out[k] = s
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readSliceOfMaps(raw any) []map[string]any {
|
||||
rawSlice, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]any, 0, len(rawSlice))
|
||||
for _, item := range rawSlice {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// valueAt reads key from raw (when raw is a map[string]any) and returns its
|
||||
// value as T, or the zero value of T if raw isn't a map, the key is absent, or
|
||||
// the stored value isn't a T. Used to pull typed fields out of the untyped v1
|
||||
// dashboard blob.
|
||||
func valueAt[T any](raw any, key string) T {
|
||||
var zero T
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return zero
|
||||
}
|
||||
v, _ := m[key].(T)
|
||||
return v
|
||||
}
|
||||
|
||||
// intAt is a thin wrapper over valueAt: JSON decodes numbers as float64, so an
|
||||
// integer field must be read as float64 and narrowed.
|
||||
func intAt(raw any, key string) int {
|
||||
return int(valueAt[float64](raw, key))
|
||||
}
|
||||
|
||||
// decodeMapInto converts an untyped map[string]any into a typed T by
|
||||
// round-tripping through JSON, letting encoding/json (struct tags, custom
|
||||
// UnmarshalJSON) do the field mapping instead of hand-copying out of the map.
|
||||
func decodeMapInto[T any](src map[string]any) (T, error) {
|
||||
var dst T
|
||||
bytes, err := json.Marshal(src)
|
||||
if err != nil {
|
||||
return dst, err
|
||||
}
|
||||
if err := json.Unmarshal(bytes, &dst); err != nil {
|
||||
return dst, err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
138
pkg/types/dashboardtypes/perses_v1_to_v2_layouts.go
Normal file
138
pkg/types/dashboardtypes/perses_v1_to_v2_layouts.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/perses/spec/go/common"
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Layouts (data.layout + data.panelMap)
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// convertV1Layouts groups v1 react-grid-layout entries by section. Each row
|
||||
// widget (panelTypes == "row") in `widgets` plus its `panelMap` entry becomes
|
||||
// a separate v2 grid layout with a collapsible display. Widgets that are not
|
||||
// part of any section land in a default unnamed grid (added only if any such
|
||||
// widgets exist).
|
||||
func convertV1Layouts(data StorableDashboardData) []Layout {
|
||||
layoutsRaw := readSliceOfMaps(data["layout"])
|
||||
if len(layoutsRaw) == 0 {
|
||||
return nil
|
||||
}
|
||||
panelMap, _ := data["panelMap"].(map[string]any)
|
||||
|
||||
rows, widgetIDToRow := indexRows(data["widgets"], panelMap)
|
||||
|
||||
type bucket struct {
|
||||
title string
|
||||
open bool
|
||||
isRow bool
|
||||
layouts []map[string]any
|
||||
ordering int
|
||||
}
|
||||
rootBucket := &bucket{}
|
||||
rowBuckets := make(map[string]*bucket, len(rows))
|
||||
for _, row := range rows {
|
||||
rowBuckets[row.id] = &bucket{
|
||||
title: row.title,
|
||||
open: !row.collapsed,
|
||||
isRow: true,
|
||||
ordering: row.ordering,
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range layoutsRaw {
|
||||
widgetID, _ := item["i"].(string)
|
||||
if widgetID == "" {
|
||||
continue
|
||||
}
|
||||
if rowID, ok := widgetIDToRow[widgetID]; ok {
|
||||
if b, ok := rowBuckets[rowID]; ok {
|
||||
b.layouts = append(b.layouts, item)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// row widgets themselves shouldn't end up as items in the root grid;
|
||||
// they exist only to anchor their section.
|
||||
if _, isRow := rowBuckets[widgetID]; isRow {
|
||||
continue
|
||||
}
|
||||
rootBucket.layouts = append(rootBucket.layouts, item)
|
||||
}
|
||||
|
||||
out := make([]Layout, 0, len(rows)+1)
|
||||
if len(rootBucket.layouts) > 0 {
|
||||
out = append(out, gridLayoutFromBucket("", true, false, rootBucket.layouts))
|
||||
}
|
||||
|
||||
rowKeys := make([]string, 0, len(rowBuckets))
|
||||
for id := range rowBuckets {
|
||||
rowKeys = append(rowKeys, id)
|
||||
}
|
||||
sort.SliceStable(rowKeys, func(i, j int) bool {
|
||||
return rowBuckets[rowKeys[i]].ordering < rowBuckets[rowKeys[j]].ordering
|
||||
})
|
||||
for _, id := range rowKeys {
|
||||
b := rowBuckets[id]
|
||||
out = append(out, gridLayoutFromBucket(b.title, b.open, true, b.layouts))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type rowInfo struct {
|
||||
id string
|
||||
title string
|
||||
collapsed bool
|
||||
ordering int
|
||||
}
|
||||
|
||||
func indexRows(widgetsRaw any, panelMap map[string]any) ([]rowInfo, map[string]string) {
|
||||
widgets := readSliceOfMaps(widgetsRaw)
|
||||
rows := make([]rowInfo, 0)
|
||||
widgetToRow := make(map[string]string)
|
||||
for i, w := range widgets {
|
||||
if t, _ := w["panelTypes"].(string); t != "row" {
|
||||
continue
|
||||
}
|
||||
id, _ := w["id"].(string)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
title, _ := w["title"].(string)
|
||||
row := rowInfo{id: id, title: title, ordering: i}
|
||||
if pm, ok := panelMap[id].(map[string]any); ok {
|
||||
row.collapsed = valueAt[bool](pm, "collapsed")
|
||||
for _, child := range readSliceOfMaps(pm["widgets"]) {
|
||||
if childID, _ := child["i"].(string); childID != "" {
|
||||
widgetToRow[childID] = id
|
||||
}
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, widgetToRow
|
||||
}
|
||||
|
||||
func gridLayoutFromBucket(title string, open, isRow bool, items []map[string]any) Layout {
|
||||
spec := dashboard.GridLayoutSpec{Items: make([]dashboard.GridItem, 0, len(items))}
|
||||
if title != "" || isRow {
|
||||
spec.Display = &dashboard.GridLayoutDisplay{Title: title}
|
||||
if isRow {
|
||||
spec.Display.Collapse = &dashboard.GridLayoutCollapse{Open: open}
|
||||
}
|
||||
}
|
||||
for _, item := range items {
|
||||
widgetID, _ := item["i"].(string)
|
||||
spec.Items = append(spec.Items, dashboard.GridItem{
|
||||
X: intAt(item, "x"),
|
||||
Y: intAt(item, "y"),
|
||||
Width: intAt(item, "w"),
|
||||
Height: intAt(item, "h"),
|
||||
Content: &common.JSONRef{Ref: fmt.Sprintf("#/spec/panels/%s", widgetID)},
|
||||
})
|
||||
}
|
||||
return Layout{Kind: dashboard.KindGridLayout, Spec: &spec}
|
||||
}
|
||||
449
pkg/types/dashboardtypes/perses_v1_to_v2_panels.go
Normal file
449
pkg/types/dashboardtypes/perses_v1_to_v2_panels.go
Normal file
@@ -0,0 +1,449 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Widgets → Panels
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// convertV1Panels walks the v1 `widgets` array and produces v2 panels keyed by
|
||||
// the v1 widget id. WidgetRow entries (panelTypes == "row") are dropped here
|
||||
// and consumed by convertV1Layouts as section headers.
|
||||
func convertV1Panels(raw any) map[string]*Panel {
|
||||
rawSlice, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
panels := make(map[string]*Panel, len(rawSlice))
|
||||
for _, item := range rawSlice {
|
||||
widget, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
id, _ := widget["id"].(string)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
panelType, _ := widget["panelTypes"].(string)
|
||||
var panel *Panel
|
||||
switch panelType {
|
||||
case "graph":
|
||||
panel = convertGraphWidget(widget)
|
||||
case "bar":
|
||||
panel = convertBarWidget(widget)
|
||||
case "value":
|
||||
panel = convertValueWidget(widget)
|
||||
case "pie":
|
||||
panel = convertPieWidget(widget)
|
||||
case "table":
|
||||
panel = convertTableWidget(widget)
|
||||
case "histogram":
|
||||
panel = convertHistogramWidget(widget)
|
||||
case "list":
|
||||
panel = convertListWidget(widget)
|
||||
default:
|
||||
// "row" (section header) is handled by the layout pass; unknown kinds skipped.
|
||||
continue
|
||||
}
|
||||
if panel == nil {
|
||||
continue
|
||||
}
|
||||
panels[id] = panel
|
||||
}
|
||||
return panels
|
||||
}
|
||||
|
||||
func convertGraphWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindTimeSeries,
|
||||
Spec: &TimeSeriesPanelSpec{
|
||||
Visualization: TimeSeriesVisualization{
|
||||
BasicVisualization: basicVisualization(w),
|
||||
FillSpans: valueAt[bool](w, "fillSpans"),
|
||||
},
|
||||
Formatting: panelFormatting(w),
|
||||
ChartAppearance: TimeSeriesChartAppearance{
|
||||
LineInterpolation: mapV1Enum(w["lineInterpolation"], LineInterpolationSpline,
|
||||
LineInterpolationLinear, LineInterpolationSpline, LineInterpolationStepAfter, LineInterpolationStepBefore),
|
||||
ShowPoints: valueAt[bool](w, "showPoints"),
|
||||
LineStyle: mapV1Enum(w["lineStyle"], LineStyleSolid, LineStyleSolid, LineStyleDashed),
|
||||
FillMode: mapV1Enum(w["fillMode"], FillModeSolid, FillModeSolid, FillModeGradient, FillModeNone),
|
||||
SpanGaps: mapV1SpanGaps(w["spanGaps"]),
|
||||
},
|
||||
Axes: axesFromWidget(w),
|
||||
Legend: legendFromWidget(w),
|
||||
Thresholds: mapV1ThresholdsWithLabel(w["thresholds"]),
|
||||
},
|
||||
},
|
||||
Queries: convertV1WidgetQuery(w, PanelKindTimeSeries),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func convertBarWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindBarChart,
|
||||
Spec: &BarChartPanelSpec{
|
||||
Visualization: BarChartVisualization{
|
||||
BasicVisualization: basicVisualization(w),
|
||||
FillSpans: valueAt[bool](w, "fillSpans"),
|
||||
StackedBarChart: valueAt[bool](w, "stackedBarChart"),
|
||||
},
|
||||
Formatting: panelFormatting(w),
|
||||
Axes: axesFromWidget(w),
|
||||
Legend: legendFromWidget(w),
|
||||
Thresholds: mapV1ThresholdsWithLabel(w["thresholds"]),
|
||||
},
|
||||
},
|
||||
Queries: convertV1WidgetQuery(w, PanelKindBarChart),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func convertValueWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindNumber,
|
||||
Spec: &NumberPanelSpec{
|
||||
Visualization: basicVisualization(w),
|
||||
Formatting: panelFormatting(w),
|
||||
Thresholds: mapV1ComparisonThresholds(w["thresholds"]),
|
||||
},
|
||||
},
|
||||
Queries: convertV1WidgetQuery(w, PanelKindNumber),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func convertPieWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindPieChart,
|
||||
Spec: &PieChartPanelSpec{
|
||||
Visualization: basicVisualization(w),
|
||||
Formatting: panelFormatting(w),
|
||||
Legend: legendFromWidget(w),
|
||||
},
|
||||
},
|
||||
Queries: convertV1WidgetQuery(w, PanelKindPieChart),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func convertTableWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindTable,
|
||||
Spec: &TablePanelSpec{
|
||||
Visualization: basicVisualization(w),
|
||||
Formatting: TableFormatting{
|
||||
ColumnUnits: readStringMap(w["columnUnits"]),
|
||||
DecimalPrecision: mapV1Precision(w["decimalPrecision"]),
|
||||
},
|
||||
Thresholds: mapV1TableThresholds(w["thresholds"]),
|
||||
},
|
||||
},
|
||||
Queries: convertV1WidgetQuery(w, PanelKindTable),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func convertHistogramWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindHistogram,
|
||||
Spec: &HistogramPanelSpec{
|
||||
HistogramBuckets: HistogramBuckets{
|
||||
BucketCount: ptrValueAt[float64](w, "bucketCount"),
|
||||
BucketWidth: ptrValueAt[float64](w, "bucketWidth"),
|
||||
MergeAllActiveQueries: valueAt[bool](w, "mergeAllActiveQueries"),
|
||||
},
|
||||
Legend: legendFromWidget(w),
|
||||
},
|
||||
},
|
||||
Queries: convertV1WidgetQuery(w, PanelKindHistogram),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func convertListWidget(w map[string]any) *Panel {
|
||||
return &Panel{
|
||||
Kind: "Panel",
|
||||
Spec: PanelSpec{
|
||||
Display: widgetDisplay(w),
|
||||
Plugin: PanelPlugin{
|
||||
Kind: PanelKindList,
|
||||
Spec: &ListPanelSpec{
|
||||
SelectFields: mapV1SelectFields(w),
|
||||
},
|
||||
},
|
||||
Queries: convertV1WidgetQuery(w, PanelKindList),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Panel-spec shared helpers
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func widgetDisplay(w map[string]any) Display {
|
||||
title, _ := w["title"].(string)
|
||||
description, _ := w["description"].(string)
|
||||
return Display{Name: title, Description: description}
|
||||
}
|
||||
|
||||
func basicVisualization(w map[string]any) BasicVisualization {
|
||||
return BasicVisualization{TimePreference: mapV1TimePreference(w["timePreferance"])}
|
||||
}
|
||||
|
||||
func panelFormatting(w map[string]any) PanelFormatting {
|
||||
unit, _ := w["yAxisUnit"].(string)
|
||||
return PanelFormatting{Unit: unit, DecimalPrecision: mapV1Precision(w["decimalPrecision"])}
|
||||
}
|
||||
|
||||
func axesFromWidget(w map[string]any) Axes {
|
||||
return Axes{
|
||||
SoftMin: ptrValueAt[float64](w, "softMin"),
|
||||
SoftMax: ptrValueAt[float64](w, "softMax"),
|
||||
IsLogScale: valueAt[bool](w, "isLogScale"),
|
||||
}
|
||||
}
|
||||
|
||||
func legendFromWidget(w map[string]any) Legend {
|
||||
return Legend{
|
||||
Position: mapV1Enum(w["legendPosition"], LegendPositionBottom, LegendPositionBottom, LegendPositionRight),
|
||||
CustomColors: readStringMap(w["customLegendColors"]),
|
||||
}
|
||||
}
|
||||
|
||||
func mapV1SelectFields(w map[string]any) []telemetrytypes.TelemetryFieldKey {
|
||||
if raw, ok := w["selectedLogFields"].([]any); ok && len(raw) > 0 {
|
||||
return decodeTelemetryFields(raw)
|
||||
}
|
||||
if raw, ok := w["selectedTracesFields"].([]any); ok && len(raw) > 0 {
|
||||
return decodeTelemetryFields(raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeTelemetryFields(raw []any) []telemetrytypes.TelemetryFieldKey {
|
||||
bytes, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var fields []telemetrytypes.TelemetryFieldKey
|
||||
if err := json.Unmarshal(bytes, &fields); err != nil {
|
||||
return nil
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Panel field mappers
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// v1 stores timePreferance as `GLOBAL_TIME`, `LAST_5_MIN`, … (see
|
||||
// frontend/src/container/NewWidget/RightContainer/timeItems.ts). v2 uses the
|
||||
// lowercase form, so the translation is just downcase.
|
||||
func mapV1TimePreference(raw any) TimePreference {
|
||||
s, ok := raw.(string)
|
||||
if !ok || s == "" {
|
||||
return TimePreferenceGlobalTime
|
||||
}
|
||||
candidate := TimePreference{valuer.NewString(strings.ToLower(s))}
|
||||
for _, allowed := range candidate.Enum() {
|
||||
if allowed == candidate {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return TimePreferenceGlobalTime
|
||||
}
|
||||
|
||||
func mapV1Precision(raw any) PrecisionOption {
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
candidate := PrecisionOption{valuer.NewString(v)}
|
||||
for _, allowed := range candidate.Enum() {
|
||||
if allowed == candidate {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
case float64:
|
||||
n := int(v)
|
||||
if n >= 0 && n <= 4 {
|
||||
return PrecisionOption{valuer.NewString(strconv.Itoa(n))}
|
||||
}
|
||||
}
|
||||
return PrecisionOption2
|
||||
}
|
||||
|
||||
// mapV1Enum picks the v1 string value if it matches one of the allowed v2
|
||||
// values, otherwise returns the fallback. v1 frontend enums (lineInterpolation,
|
||||
// lineStyle, fillMode, legendPosition) already use the v2 lowercase form.
|
||||
func mapV1Enum[T interface{ StringValue() string }](raw any, fallback T, allowed ...T) T {
|
||||
s, ok := raw.(string)
|
||||
if !ok || s == "" {
|
||||
return fallback
|
||||
}
|
||||
for _, a := range allowed {
|
||||
if a.StringValue() == s {
|
||||
return a
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// v1 spanGaps is `boolean | number`. true → span every gap; false → never span;
|
||||
// a number is interpreted (per frontend SeriesProps.spanGaps docs) as an
|
||||
// X-axis threshold in seconds.
|
||||
func mapV1SpanGaps(raw any) SpanGaps {
|
||||
switch v := raw.(type) {
|
||||
case bool:
|
||||
if v {
|
||||
return SpanGaps{FillOnlyBelow: false}
|
||||
}
|
||||
return SpanGaps{FillOnlyBelow: true}
|
||||
case float64:
|
||||
dur, err := valuer.ParseTextDuration(time.Duration(v * float64(time.Second)).String())
|
||||
if err != nil {
|
||||
return SpanGaps{FillOnlyBelow: false}
|
||||
}
|
||||
return SpanGaps{FillOnlyBelow: true, FillLessThan: dur}
|
||||
}
|
||||
return SpanGaps{FillOnlyBelow: false}
|
||||
}
|
||||
|
||||
func mapV1ThresholdsWithLabel(raw any) []ThresholdWithLabel {
|
||||
rawSlice := readSliceOfMaps(raw)
|
||||
if len(rawSlice) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]ThresholdWithLabel, 0, len(rawSlice))
|
||||
for _, t := range rawSlice {
|
||||
color, _ := t["thresholdColor"].(string)
|
||||
label, _ := t["thresholdLabel"].(string)
|
||||
if color == "" || label == "" {
|
||||
// v2 ThresholdWithLabel requires both; drop entries that wouldn't validate.
|
||||
continue
|
||||
}
|
||||
value, _ := t["thresholdValue"].(float64)
|
||||
unit, _ := t["thresholdUnit"].(string)
|
||||
out = append(out, ThresholdWithLabel{Value: value, Unit: unit, Color: color, Label: label})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mapV1ComparisonThresholds(raw any) []ComparisonThreshold {
|
||||
rawSlice := readSliceOfMaps(raw)
|
||||
if len(rawSlice) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]ComparisonThreshold, 0, len(rawSlice))
|
||||
for _, t := range rawSlice {
|
||||
color, _ := t["thresholdColor"].(string)
|
||||
if color == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, ComparisonThreshold{
|
||||
Value: valueAt[float64](t, "thresholdValue"),
|
||||
Operator: mapV1ComparisonOperator(t["thresholdOperator"]),
|
||||
Unit: valueAt[string](t, "thresholdUnit"),
|
||||
Color: color,
|
||||
Format: mapV1ThresholdFormat(t["thresholdFormat"]),
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mapV1TableThresholds(raw any) []TableThreshold {
|
||||
rawSlice := readSliceOfMaps(raw)
|
||||
if len(rawSlice) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]TableThreshold, 0, len(rawSlice))
|
||||
for _, t := range rawSlice {
|
||||
color, _ := t["thresholdColor"].(string)
|
||||
columnName, _ := t["thresholdTableOptions"].(string)
|
||||
if color == "" || columnName == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, TableThreshold{
|
||||
ComparisonThreshold: ComparisonThreshold{
|
||||
Value: valueAt[float64](t, "thresholdValue"),
|
||||
Operator: mapV1ComparisonOperator(t["thresholdOperator"]),
|
||||
Unit: valueAt[string](t, "thresholdUnit"),
|
||||
Color: color,
|
||||
Format: mapV1ThresholdFormat(t["thresholdFormat"]),
|
||||
},
|
||||
ColumnName: columnName,
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mapV1ComparisonOperator(raw any) ComparisonOperator {
|
||||
s, _ := raw.(string)
|
||||
switch s {
|
||||
case ">":
|
||||
return ComparisonOperatorAbove
|
||||
case ">=":
|
||||
return ComparisonOperatorAboveOrEqual
|
||||
case "<":
|
||||
return ComparisonOperatorBelow
|
||||
case "<=":
|
||||
return ComparisonOperatorBelowOrEqual
|
||||
case "=":
|
||||
return ComparisonOperatorEqual
|
||||
case "!=":
|
||||
return ComparisonOperatorNotEqual
|
||||
}
|
||||
return ComparisonOperatorAbove
|
||||
}
|
||||
|
||||
func mapV1ThresholdFormat(raw any) ThresholdFormat {
|
||||
s, _ := raw.(string)
|
||||
switch strings.ToLower(s) {
|
||||
case "background":
|
||||
return ThresholdFormatBackground
|
||||
case "text":
|
||||
return ThresholdFormatText
|
||||
}
|
||||
return ThresholdFormatText
|
||||
}
|
||||
249
pkg/types/dashboardtypes/perses_v1_to_v2_queries.go
Normal file
249
pkg/types/dashboardtypes/perses_v1_to_v2_queries.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/transition"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Queries
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// convertV1WidgetQuery returns exactly one Query (per Spec.Validate). The
|
||||
// kind chosen depends on the v1 widget query shape:
|
||||
// - single promql → signoz/PromQLQuery
|
||||
// - single clickhouse_sql → signoz/ClickHouseSQL
|
||||
// - exactly one builder query → signoz/BuilderQuery (PanelKindList only)
|
||||
// - everything else → signoz/CompositeQuery wrapping all envelopes
|
||||
//
|
||||
// Builder queries are routed through transition.WrapInV5Envelope, which
|
||||
// translates v4 builder-field names (orderBy/selectColumns/dataSource) into
|
||||
// their v5 equivalents and adds the `signal` field required by
|
||||
// BuilderQuerySpec's per-signal dispatch.
|
||||
func convertV1WidgetQuery(widget map[string]any, panelKind PanelPluginKind) []Query {
|
||||
envelopes, signal := collectV1QueryEnvelopes(widget)
|
||||
if len(envelopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
requestType := requestTypeForPanel(panelKind)
|
||||
|
||||
// List panels must use signoz/BuilderQuery (the only kind in
|
||||
// allowedQueryKinds[PanelKindList]).
|
||||
if panelKind == PanelKindList {
|
||||
first := envelopes[0]
|
||||
if t, _ := first["type"].(string); t == string(qb.QueryTypeBuilder.StringValue()) {
|
||||
spec := parseBuilderQuerySpec(first["spec"], signal)
|
||||
if spec == nil {
|
||||
return nil
|
||||
}
|
||||
return []Query{{
|
||||
Kind: requestType,
|
||||
Spec: QuerySpec{
|
||||
Name: valueAt[string](first["spec"], "name"),
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: spec},
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
// Single non-builder query → use its native kind directly. Cleaner JSON
|
||||
// than wrapping in CompositeQuery for the common single-query case.
|
||||
if len(envelopes) == 1 {
|
||||
if q := singleQueryFromEnvelope(envelopes[0], requestType); q != nil {
|
||||
return []Query{*q}
|
||||
}
|
||||
}
|
||||
|
||||
// Default: wrap in CompositeQuery.
|
||||
composite, err := parseCompositeFromEnvelopes(envelopes)
|
||||
if err != nil || composite == nil {
|
||||
return nil
|
||||
}
|
||||
return []Query{{
|
||||
Kind: requestType,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{Kind: QueryKindComposite, Spec: composite},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// requestTypeForPanel maps a v2 panel plugin kind to the request type (result
|
||||
// shape) its queries produce. Mirrors the shape each visualization consumes:
|
||||
// time series for line/bar, scalar for number/pie/table, distribution for
|
||||
// histogram, raw rows for list.
|
||||
func requestTypeForPanel(panelKind PanelPluginKind) qb.RequestType {
|
||||
switch panelKind {
|
||||
case PanelKindTimeSeries, PanelKindBarChart:
|
||||
return qb.RequestTypeTimeSeries
|
||||
case PanelKindNumber, PanelKindPieChart, PanelKindTable:
|
||||
return qb.RequestTypeScalar
|
||||
case PanelKindHistogram:
|
||||
return qb.RequestTypeDistribution
|
||||
case PanelKindList:
|
||||
return qb.RequestTypeRaw
|
||||
}
|
||||
return qb.RequestTypeTimeSeries
|
||||
}
|
||||
|
||||
// collectV1QueryEnvelopes inspects widget.query.queryType and produces a
|
||||
// flattened list of v5-shaped envelopes. The returned signal is the dominant
|
||||
// builder signal (if any), used for typed builder-query dispatch.
|
||||
func collectV1QueryEnvelopes(widget map[string]any) ([]map[string]any, telemetrytypes.Signal) {
|
||||
queryMap, ok := widget["query"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, telemetrytypes.Signal{}
|
||||
}
|
||||
queryType, _ := queryMap["queryType"].(string)
|
||||
|
||||
switch queryType {
|
||||
case "promql":
|
||||
var out []map[string]any
|
||||
for _, q := range readSliceOfMaps(queryMap["promql"]) {
|
||||
out = append(out, promQLEnvelope(q))
|
||||
}
|
||||
return out, telemetrytypes.Signal{}
|
||||
|
||||
case "clickhouse_sql":
|
||||
var out []map[string]any
|
||||
for _, q := range readSliceOfMaps(queryMap["clickhouse_sql"]) {
|
||||
out = append(out, clickhouseEnvelope(q))
|
||||
}
|
||||
return out, telemetrytypes.Signal{}
|
||||
|
||||
case "builder":
|
||||
builder, _ := queryMap["builder"].(map[string]any)
|
||||
if builder == nil {
|
||||
return nil, telemetrytypes.Signal{}
|
||||
}
|
||||
var out []map[string]any
|
||||
var signal telemetrytypes.Signal
|
||||
wrap := transition.NewMigrateCommon(nil)
|
||||
for _, q := range readSliceOfMaps(builder["queryData"]) {
|
||||
name := valueAt[string](q, "queryName")
|
||||
out = append(out, wrap.WrapInV5Envelope(name, q, string(qb.QueryTypeBuilder.StringValue())))
|
||||
if signal.IsZero() {
|
||||
signal = signalFromDataSource(q["dataSource"])
|
||||
}
|
||||
}
|
||||
for _, f := range readSliceOfMaps(builder["queryFormulas"]) {
|
||||
name := valueAt[string](f, "queryName")
|
||||
out = append(out, wrap.WrapInV5Envelope(name, f, string(qb.QueryTypeFormula.StringValue())))
|
||||
}
|
||||
for _, op := range readSliceOfMaps(builder["queryTraceOperator"]) {
|
||||
name := valueAt[string](op, "queryName")
|
||||
out = append(out, wrap.WrapInV5Envelope(name, op, string(qb.QueryTypeTraceOperator.StringValue())))
|
||||
}
|
||||
return out, signal
|
||||
}
|
||||
return nil, telemetrytypes.Signal{}
|
||||
}
|
||||
|
||||
func promQLEnvelope(q map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"type": qb.QueryTypePromQL.StringValue(),
|
||||
"spec": map[string]any{
|
||||
"name": q["name"],
|
||||
"query": q["query"],
|
||||
"disabled": q["disabled"],
|
||||
"legend": q["legend"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func clickhouseEnvelope(q map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"type": qb.QueryTypeClickHouseSQL.StringValue(),
|
||||
"spec": map[string]any{
|
||||
"name": q["name"],
|
||||
"query": q["query"],
|
||||
"disabled": q["disabled"],
|
||||
"legend": q["legend"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// singleQueryFromEnvelope returns a typed Query for an envelope whose type is
|
||||
// promql/clickhouse_sql. Builder envelopes always fall through to Composite so
|
||||
// composite-only panel kinds (TimeSeries/BarChart/etc.) get uniform queries.
|
||||
func singleQueryFromEnvelope(envelope map[string]any, requestType qb.RequestType) *Query {
|
||||
t, _ := envelope["type"].(string)
|
||||
spec, _ := envelope["spec"].(map[string]any)
|
||||
switch t {
|
||||
case qb.QueryTypePromQL.StringValue():
|
||||
prom, err := decodeMapInto[qb.PromQuery](spec)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &Query{
|
||||
Kind: requestType,
|
||||
Spec: QuerySpec{
|
||||
Name: prom.Name,
|
||||
Plugin: QueryPlugin{Kind: QueryKindPromQL, Spec: &prom},
|
||||
},
|
||||
}
|
||||
case qb.QueryTypeClickHouseSQL.StringValue():
|
||||
ch, err := decodeMapInto[qb.ClickHouseQuery](spec)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &Query{
|
||||
Kind: requestType,
|
||||
Spec: QuerySpec{
|
||||
Name: ch.Name,
|
||||
Plugin: QueryPlugin{Kind: QueryKindClickHouseSQL, Spec: &ch},
|
||||
},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCompositeFromEnvelopes(envelopes []map[string]any) (*CompositeQuerySpec, error) {
|
||||
bytes, err := json.Marshal(envelopes)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal v1 query envelopes")
|
||||
}
|
||||
var parsed []qb.QueryEnvelope
|
||||
if err := json.Unmarshal(bytes, &parsed); err != nil {
|
||||
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidWidgetQuery, "decode v5 query envelopes")
|
||||
}
|
||||
return &CompositeQuerySpec{Queries: parsed}, nil
|
||||
}
|
||||
|
||||
func parseBuilderQuerySpec(rawSpec any, signal telemetrytypes.Signal) any {
|
||||
spec, ok := rawSpec.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !signal.IsZero() {
|
||||
spec["signal"] = signal.StringValue()
|
||||
}
|
||||
bytes, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
parsed, err := qb.UnmarshalBuilderQueryBySignal(bytes)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func signalFromDataSource(raw any) telemetrytypes.Signal {
|
||||
s, _ := raw.(string)
|
||||
switch s {
|
||||
case "traces":
|
||||
return telemetrytypes.SignalTraces
|
||||
case "logs":
|
||||
return telemetrytypes.SignalLogs
|
||||
case "metrics":
|
||||
return telemetrytypes.SignalMetrics
|
||||
}
|
||||
return telemetrytypes.Signal{}
|
||||
}
|
||||
110
pkg/types/dashboardtypes/perses_v1_to_v2_tags.go
Normal file
110
pkg/types/dashboardtypes/perses_v1_to_v2_tags.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Tags
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// v1 carries tags as a flat []string; v2 tags are (key, value) pairs. Each v1
|
||||
// string is normalized into a pair following the rules in pkg/types/migration.md
|
||||
// (separator split, empty-side fallback, reserved-key prefix, `/` scrub). Tags
|
||||
// that normalize to the same (lower(key), lower(value)) within a dashboard are
|
||||
// collapsed, first occurrence winning the display casing.
|
||||
//
|
||||
// Characters still illegal after normalization (e.g. spaces) are left intact:
|
||||
// such tags fail tag validation downstream and are logged for the customer to
|
||||
// fix, per the migration's dry-run plan.
|
||||
|
||||
// defaultV1TagKey is the key assigned when a v1 tag string has no usable
|
||||
// separator (or one side of the split is empty).
|
||||
const defaultV1TagKey = "tag"
|
||||
|
||||
func convertV1TagsForOrg(orgID valuer.UUID, raw any) []*tagtypes.Tag {
|
||||
rawSlice, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(rawSlice))
|
||||
out := make([]*tagtypes.Tag, 0, len(rawSlice))
|
||||
for _, item := range rawSlice {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, value, ok := normalizeV1Tag(s)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
dedupKey := strings.ToLower(key) + "\x00" + strings.ToLower(value)
|
||||
if _, dup := seen[dedupKey]; dup {
|
||||
continue
|
||||
}
|
||||
seen[dedupKey] = struct{}{}
|
||||
out = append(out, tagtypes.NewTag(orgID, coretypes.KindDashboard, key, value))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeV1Tag derives a (key, value) pair from one v1 tag string per the
|
||||
// ordered rules in pkg/types/migration.md. ok is false when the string has no
|
||||
// usable content (empty after trimming, or a bare separator).
|
||||
func normalizeV1Tag(s string) (string, string, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
var key, value string
|
||||
var ok bool
|
||||
switch {
|
||||
case strings.Contains(s, ":"):
|
||||
key, value, ok = splitV1Tag(s, ":")
|
||||
// Only the first ":" separates key from value; collapse the rest.
|
||||
value = strings.ReplaceAll(value, ":", "_")
|
||||
case strings.Contains(s, "/"):
|
||||
key, value, ok = splitV1Tag(s, "/")
|
||||
default:
|
||||
key, value, ok = defaultV1TagKey, s, true
|
||||
}
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Reserved-key collision: prefix with "_" so the list-query DSL stays
|
||||
// unambiguous. Matched case-insensitively against the DSL column names.
|
||||
if _, reserved := reservedDSLKeys[DSLKey(strings.ToLower(key))]; reserved {
|
||||
key = "_" + key
|
||||
}
|
||||
|
||||
// Stored tags must never contain "/" (input validation forbids it).
|
||||
key = strings.ReplaceAll(key, "/", "_")
|
||||
value = strings.ReplaceAll(value, "/", "_")
|
||||
|
||||
return key, value, true
|
||||
}
|
||||
|
||||
// splitV1Tag splits s at the first occurrence of sep, trimming each side. An
|
||||
// empty side collapses to the default key with the non-empty side as the value;
|
||||
// if both sides are empty (a bare separator) ok is false.
|
||||
func splitV1Tag(s, sep string) (string, string, bool) {
|
||||
left, right, _ := strings.Cut(s, sep)
|
||||
left = strings.TrimSpace(left)
|
||||
right = strings.TrimSpace(right)
|
||||
switch {
|
||||
case left == "" && right == "":
|
||||
return "", "", false
|
||||
case left == "":
|
||||
return defaultV1TagKey, right, true
|
||||
case right == "":
|
||||
return defaultV1TagKey, left, true
|
||||
default:
|
||||
return left, right, true
|
||||
}
|
||||
}
|
||||
766
pkg/types/dashboardtypes/perses_v1_to_v2_test.go
Normal file
766
pkg/types/dashboardtypes/perses_v1_to_v2_test.go
Normal file
@@ -0,0 +1,766 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
"github.com/perses/spec/go/dashboard/variable"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConvertV1TagsForOrg(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
type kv struct{ key, value string }
|
||||
|
||||
cases := []struct {
|
||||
scenario string
|
||||
rawTags any
|
||||
expectedTags []kv
|
||||
}{
|
||||
{
|
||||
scenario: "no separator uses the default key",
|
||||
rawTags: []any{"apm", "latency", "throughput"},
|
||||
expectedTags: []kv{{"tag", "apm"}, {"tag", "latency"}, {"tag", "throughput"}},
|
||||
},
|
||||
{
|
||||
scenario: "colon splits into key and value",
|
||||
rawTags: []any{"env:prod", "team : backend"},
|
||||
expectedTags: []kv{{"env", "prod"}, {"team", "backend"}},
|
||||
},
|
||||
{
|
||||
scenario: "slash splits into key and value when no colon present",
|
||||
rawTags: []any{"team/backend"},
|
||||
expectedTags: []kv{{"team", "backend"}},
|
||||
},
|
||||
{
|
||||
scenario: "colon takes precedence over slash and slash is scrubbed",
|
||||
rawTags: []any{"team/eng:prod", "team/eng:my/path"},
|
||||
expectedTags: []kv{{"team_eng", "prod"}, {"team_eng", "my_path"}},
|
||||
},
|
||||
{
|
||||
scenario: "empty left side falls back to the default key",
|
||||
rawTags: []any{":prod"},
|
||||
expectedTags: []kv{{"tag", "prod"}},
|
||||
},
|
||||
{
|
||||
scenario: "empty right side keeps the left side as the value",
|
||||
rawTags: []any{"env:"},
|
||||
expectedTags: []kv{{"tag", "env"}},
|
||||
},
|
||||
{
|
||||
scenario: "extra colons in the value collapse to underscores",
|
||||
rawTags: []any{"a:b:c"},
|
||||
expectedTags: []kv{{"a", "b_c"}},
|
||||
},
|
||||
{
|
||||
scenario: "extra slashes in the value are scrubbed",
|
||||
rawTags: []any{"a/b/c"},
|
||||
expectedTags: []kv{{"a", "b_c"}},
|
||||
},
|
||||
{
|
||||
scenario: "reserved key gets an underscore prefix",
|
||||
rawTags: []any{"name:foo", "Source:bar"},
|
||||
expectedTags: []kv{{"_name", "foo"}, {"_Source", "bar"}},
|
||||
},
|
||||
{
|
||||
scenario: "drops empty, whitespace-only, and bare-separator entries",
|
||||
rawTags: []any{"", " ", ":", "/", "apm"},
|
||||
expectedTags: []kv{{"tag", "apm"}},
|
||||
},
|
||||
{
|
||||
scenario: "dedupes case-insensitive duplicates, first casing wins",
|
||||
rawTags: []any{"Env:Prod", "env:PROD"},
|
||||
expectedTags: []kv{{"Env", "Prod"}},
|
||||
},
|
||||
{
|
||||
scenario: "returns nil for missing tags field",
|
||||
rawTags: nil,
|
||||
expectedTags: nil,
|
||||
},
|
||||
{
|
||||
scenario: "ignores non-string elements",
|
||||
rawTags: []any{"apm", 42, true, "logs"},
|
||||
expectedTags: []kv{{"tag", "apm"}, {"tag", "logs"}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
tags := convertV1TagsForOrg(orgID, tc.rawTags)
|
||||
require.Len(t, tags, len(tc.expectedTags))
|
||||
for i, expected := range tc.expectedTags {
|
||||
assert.Equal(t, expected.key, tags[i].Key)
|
||||
assert.Equal(t, expected.value, tags[i].Value)
|
||||
assert.Equal(t, orgID, tags[i].OrgID)
|
||||
assert.Equal(t, coretypes.KindDashboard, tags[i].Kind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGraphWidgetToTimeSeriesPanel(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "widget-1",
|
||||
"panelTypes": "graph",
|
||||
"title": "Request rate",
|
||||
"description": "RPS over time",
|
||||
"timePreferance": "LAST_1_HR",
|
||||
"fillSpans": true,
|
||||
"yAxisUnit": "reqps",
|
||||
"decimalPrecision": float64(3),
|
||||
"lineInterpolation": "linear",
|
||||
"lineStyle": "dashed",
|
||||
"fillMode": "gradient",
|
||||
"showPoints": true,
|
||||
"spanGaps": float64(60),
|
||||
"softMin": float64(0),
|
||||
"softMax": float64(100),
|
||||
"isLogScale": true,
|
||||
"legendPosition": "right",
|
||||
"customLegendColors": map[string]any{"A": "#ff0000", "B": "#00ff00"},
|
||||
"thresholds": []any{
|
||||
map[string]any{
|
||||
"thresholdValue": float64(90),
|
||||
"thresholdUnit": "reqps",
|
||||
"thresholdColor": "#ff0000",
|
||||
"thresholdLabel": "high",
|
||||
},
|
||||
map[string]any{
|
||||
"thresholdValue": float64(50),
|
||||
"thresholdColor": "", // missing — must be dropped
|
||||
"thresholdLabel": "missing-color",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
panel := convertGraphWidget(widget)
|
||||
require.NotNil(t, panel)
|
||||
|
||||
assert.Equal(t, PanelKindPanel, panel.Kind)
|
||||
assert.Equal(t, "Request rate", panel.Spec.Display.Name)
|
||||
assert.Equal(t, "RPS over time", panel.Spec.Display.Description)
|
||||
|
||||
assert.Equal(t, PanelKindTimeSeries, panel.Spec.Plugin.Kind)
|
||||
spec, ok := panel.Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
|
||||
require.True(t, ok, "panel plugin spec should be *TimeSeriesPanelSpec")
|
||||
|
||||
assert.Equal(t, TimePreferenceLast1Hr, spec.Visualization.TimePreference)
|
||||
assert.True(t, spec.Visualization.FillSpans)
|
||||
|
||||
assert.Equal(t, "reqps", spec.Formatting.Unit)
|
||||
assert.Equal(t, PrecisionOption3, spec.Formatting.DecimalPrecision)
|
||||
|
||||
assert.Equal(t, LineInterpolationLinear, spec.ChartAppearance.LineInterpolation)
|
||||
assert.True(t, spec.ChartAppearance.ShowPoints)
|
||||
assert.Equal(t, LineStyleDashed, spec.ChartAppearance.LineStyle)
|
||||
assert.Equal(t, FillModeGradient, spec.ChartAppearance.FillMode)
|
||||
assert.True(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow)
|
||||
assert.Equal(t, "1m0s", spec.ChartAppearance.SpanGaps.FillLessThan.StringValue())
|
||||
|
||||
require.NotNil(t, spec.Axes.SoftMin)
|
||||
assert.Equal(t, float64(0), *spec.Axes.SoftMin)
|
||||
require.NotNil(t, spec.Axes.SoftMax)
|
||||
assert.Equal(t, float64(100), *spec.Axes.SoftMax)
|
||||
assert.True(t, spec.Axes.IsLogScale)
|
||||
|
||||
assert.Equal(t, LegendPositionRight, spec.Legend.Position)
|
||||
assert.Equal(t, map[string]string{"A": "#ff0000", "B": "#00ff00"}, spec.Legend.CustomColors)
|
||||
|
||||
require.Len(t, spec.Thresholds, 1, "threshold with missing color should be dropped")
|
||||
assert.Equal(t, ThresholdWithLabel{Value: 90, Unit: "reqps", Color: "#ff0000", Label: "high"}, spec.Thresholds[0])
|
||||
}
|
||||
|
||||
func TestConvertGraphWidgetDefaultsForMissingFields(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "widget-1",
|
||||
"panelTypes": "graph",
|
||||
"title": "minimal",
|
||||
}
|
||||
|
||||
panel := convertGraphWidget(widget)
|
||||
require.NotNil(t, panel)
|
||||
|
||||
spec, ok := panel.Spec.Plugin.Spec.(*TimeSeriesPanelSpec)
|
||||
require.True(t, ok)
|
||||
|
||||
assert.Equal(t, TimePreferenceGlobalTime, spec.Visualization.TimePreference)
|
||||
assert.Equal(t, PrecisionOption2, spec.Formatting.DecimalPrecision)
|
||||
assert.Equal(t, LineInterpolationSpline, spec.ChartAppearance.LineInterpolation)
|
||||
assert.Equal(t, LineStyleSolid, spec.ChartAppearance.LineStyle)
|
||||
assert.Equal(t, FillModeSolid, spec.ChartAppearance.FillMode)
|
||||
assert.Equal(t, LegendPositionBottom, spec.Legend.Position)
|
||||
assert.False(t, spec.ChartAppearance.SpanGaps.FillOnlyBelow)
|
||||
assert.Nil(t, spec.Axes.SoftMin)
|
||||
assert.Nil(t, spec.Axes.SoftMax)
|
||||
assert.Empty(t, spec.Thresholds)
|
||||
}
|
||||
|
||||
func TestConvertV1ToV2HappyPath(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID()
|
||||
storable := &StorableDashboard{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
UserAuditable: types.UserAuditable{CreatedBy: "alice", UpdatedBy: "bob"},
|
||||
OrgID: orgID,
|
||||
Source: SourceUser,
|
||||
Name: "apm-metrics",
|
||||
Data: StorableDashboardData{
|
||||
"title": "APM Metrics",
|
||||
"description": "service overview",
|
||||
"image": "data:image/png;base64,abc",
|
||||
"tags": []any{"apm", "team:platform"},
|
||||
"widgets": []any{
|
||||
// section header — owned by the layout pass, not a panel
|
||||
map[string]any{"id": "row-1", "panelTypes": "row", "title": "Overview"},
|
||||
// graph widget → TimeSeries panel
|
||||
map[string]any{
|
||||
"id": "panel-1",
|
||||
"panelTypes": "graph",
|
||||
"title": "Latency",
|
||||
},
|
||||
// table widget → Table panel
|
||||
map[string]any{"id": "panel-2", "panelTypes": "table"},
|
||||
// widget with missing id — dropped
|
||||
map[string]any{"panelTypes": "graph", "title": "no id"},
|
||||
// unknown panel kind — silently dropped
|
||||
map[string]any{"id": "panel-3", "panelTypes": "totally-new"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
dashboard, err := storable.ConvertV1ToV2()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, dashboard)
|
||||
|
||||
assert.Equal(t, storable.ID, dashboard.ID)
|
||||
assert.Equal(t, storable.OrgID, dashboard.OrgID)
|
||||
assert.Equal(t, storable.Source, dashboard.Source)
|
||||
assert.Equal(t, storable.Name, dashboard.Name)
|
||||
assert.Equal(t, SchemaVersion, dashboard.SchemaVersion)
|
||||
assert.Equal(t, "data:image/png;base64,abc", dashboard.Image)
|
||||
|
||||
assert.Equal(t, "APM Metrics", dashboard.Spec.Display.Name)
|
||||
assert.Equal(t, "service overview", dashboard.Spec.Display.Description)
|
||||
|
||||
require.Len(t, dashboard.Tags, 2)
|
||||
assert.Equal(t, "tag", dashboard.Tags[0].Key)
|
||||
assert.Equal(t, "apm", dashboard.Tags[0].Value)
|
||||
assert.Equal(t, "team", dashboard.Tags[1].Key)
|
||||
assert.Equal(t, "platform", dashboard.Tags[1].Value)
|
||||
|
||||
require.Len(t, dashboard.Spec.Panels, 2, "graph and table map; row, no-id, and unknown kinds are dropped")
|
||||
require.Contains(t, dashboard.Spec.Panels, "panel-1")
|
||||
require.Contains(t, dashboard.Spec.Panels, "panel-2")
|
||||
assert.Equal(t, PanelKindTimeSeries, dashboard.Spec.Panels["panel-1"].Spec.Plugin.Kind)
|
||||
assert.Equal(t, PanelKindTable, dashboard.Spec.Panels["panel-2"].Spec.Plugin.Kind)
|
||||
}
|
||||
|
||||
func TestConvertV1ToV2RejectsAlreadyV2(t *testing.T) {
|
||||
storable := &StorableDashboard{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
OrgID: valuer.GenerateUUID(),
|
||||
Source: SourceUser,
|
||||
Name: "already-v2",
|
||||
Data: StorableDashboardData{
|
||||
"metadata": map[string]any{"schemaVersion": SchemaVersion},
|
||||
"spec": map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
dashboard, err := storable.ConvertV1ToV2()
|
||||
assert.Nil(t, dashboard)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already in")
|
||||
}
|
||||
|
||||
func TestSpanGapsMapping(t *testing.T) {
|
||||
cases := []struct {
|
||||
scenario string
|
||||
rawSpanGaps any
|
||||
expectedFillOnlyBelow bool
|
||||
expectedFillLessThan string
|
||||
}{
|
||||
{scenario: "true spans every gap", rawSpanGaps: true, expectedFillOnlyBelow: false, expectedFillLessThan: "0s"},
|
||||
{scenario: "false spans no gaps", rawSpanGaps: false, expectedFillOnlyBelow: true, expectedFillLessThan: "0s"},
|
||||
{scenario: "number is seconds threshold", rawSpanGaps: float64(30), expectedFillOnlyBelow: true, expectedFillLessThan: "30s"},
|
||||
{scenario: "missing defaults to span all", rawSpanGaps: nil, expectedFillOnlyBelow: false, expectedFillLessThan: "0s"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
got := mapV1SpanGaps(tc.rawSpanGaps)
|
||||
assert.Equal(t, tc.expectedFillOnlyBelow, got.FillOnlyBelow)
|
||||
assert.Equal(t, tc.expectedFillLessThan, got.FillLessThan.StringValue())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Other panel-kind converters
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func TestConvertBarWidgetToBarChartPanel(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "bar-1",
|
||||
"panelTypes": "bar",
|
||||
"title": "Requests by status",
|
||||
"fillSpans": true,
|
||||
"stackedBarChart": true,
|
||||
"yAxisUnit": "reqps",
|
||||
"softMin": float64(0),
|
||||
"isLogScale": true,
|
||||
"legendPosition": "right",
|
||||
}
|
||||
|
||||
panel := convertBarWidget(widget)
|
||||
require.NotNil(t, panel)
|
||||
assert.Equal(t, PanelKindBarChart, panel.Spec.Plugin.Kind)
|
||||
|
||||
spec, ok := panel.Spec.Plugin.Spec.(*BarChartPanelSpec)
|
||||
require.True(t, ok)
|
||||
assert.True(t, spec.Visualization.FillSpans)
|
||||
assert.True(t, spec.Visualization.StackedBarChart)
|
||||
assert.Equal(t, "reqps", spec.Formatting.Unit)
|
||||
require.NotNil(t, spec.Axes.SoftMin)
|
||||
assert.Equal(t, float64(0), *spec.Axes.SoftMin)
|
||||
assert.True(t, spec.Axes.IsLogScale)
|
||||
assert.Equal(t, LegendPositionRight, spec.Legend.Position)
|
||||
}
|
||||
|
||||
func TestConvertValueWidgetToNumberPanel(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "val-1",
|
||||
"panelTypes": "value",
|
||||
"title": "Active services",
|
||||
"yAxisUnit": "count",
|
||||
"thresholds": []any{
|
||||
map[string]any{
|
||||
"thresholdValue": float64(100),
|
||||
"thresholdOperator": ">=",
|
||||
"thresholdColor": "#ff0000",
|
||||
"thresholdFormat": "Background",
|
||||
"thresholdUnit": "count",
|
||||
},
|
||||
map[string]any{
|
||||
// missing color — must be dropped
|
||||
"thresholdValue": float64(10),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
panel := convertValueWidget(widget)
|
||||
require.NotNil(t, panel)
|
||||
assert.Equal(t, PanelKindNumber, panel.Spec.Plugin.Kind)
|
||||
|
||||
spec, ok := panel.Spec.Plugin.Spec.(*NumberPanelSpec)
|
||||
require.True(t, ok)
|
||||
require.Len(t, spec.Thresholds, 1)
|
||||
assert.Equal(t, float64(100), spec.Thresholds[0].Value)
|
||||
assert.Equal(t, ComparisonOperatorAboveOrEqual, spec.Thresholds[0].Operator)
|
||||
assert.Equal(t, "#ff0000", spec.Thresholds[0].Color)
|
||||
assert.Equal(t, ThresholdFormatBackground, spec.Thresholds[0].Format)
|
||||
}
|
||||
|
||||
func TestConvertTableWidgetToTablePanel(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "tbl-1",
|
||||
"panelTypes": "table",
|
||||
"title": "Top services",
|
||||
"columnUnits": map[string]any{
|
||||
"latency": "ms",
|
||||
"errors": "count",
|
||||
},
|
||||
"thresholds": []any{
|
||||
map[string]any{
|
||||
"thresholdValue": float64(500),
|
||||
"thresholdColor": "#ff0000",
|
||||
"thresholdTableOptions": "latency",
|
||||
"thresholdOperator": ">",
|
||||
},
|
||||
map[string]any{
|
||||
// missing columnName — dropped
|
||||
"thresholdValue": float64(1),
|
||||
"thresholdColor": "#00ff00",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
panel := convertTableWidget(widget)
|
||||
require.NotNil(t, panel)
|
||||
assert.Equal(t, PanelKindTable, panel.Spec.Plugin.Kind)
|
||||
|
||||
spec, ok := panel.Spec.Plugin.Spec.(*TablePanelSpec)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "ms", spec.Formatting.ColumnUnits["latency"])
|
||||
assert.Equal(t, "count", spec.Formatting.ColumnUnits["errors"])
|
||||
require.Len(t, spec.Thresholds, 1)
|
||||
assert.Equal(t, "latency", spec.Thresholds[0].ColumnName)
|
||||
assert.Equal(t, ComparisonOperatorAbove, spec.Thresholds[0].Operator)
|
||||
}
|
||||
|
||||
func TestConvertPieWidgetToPieChartPanel(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "pie-1",
|
||||
"panelTypes": "pie",
|
||||
"title": "Share",
|
||||
"legendPosition": "right",
|
||||
}
|
||||
|
||||
panel := convertPieWidget(widget)
|
||||
require.NotNil(t, panel)
|
||||
assert.Equal(t, PanelKindPieChart, panel.Spec.Plugin.Kind)
|
||||
|
||||
spec, ok := panel.Spec.Plugin.Spec.(*PieChartPanelSpec)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, LegendPositionRight, spec.Legend.Position)
|
||||
}
|
||||
|
||||
func TestConvertHistogramWidget(t *testing.T) {
|
||||
bucketCount := float64(20)
|
||||
widget := map[string]any{
|
||||
"id": "hist-1",
|
||||
"panelTypes": "histogram",
|
||||
"title": "Latency distribution",
|
||||
"bucketCount": bucketCount,
|
||||
"mergeAllActiveQueries": true,
|
||||
}
|
||||
|
||||
panel := convertHistogramWidget(widget)
|
||||
require.NotNil(t, panel)
|
||||
assert.Equal(t, PanelKindHistogram, panel.Spec.Plugin.Kind)
|
||||
|
||||
spec, ok := panel.Spec.Plugin.Spec.(*HistogramPanelSpec)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, spec.HistogramBuckets.BucketCount)
|
||||
assert.Equal(t, bucketCount, *spec.HistogramBuckets.BucketCount)
|
||||
assert.Nil(t, spec.HistogramBuckets.BucketWidth)
|
||||
assert.True(t, spec.HistogramBuckets.MergeAllActiveQueries)
|
||||
}
|
||||
|
||||
func TestConvertListWidget(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "list-1",
|
||||
"panelTypes": "list",
|
||||
"title": "Recent logs",
|
||||
"selectedLogFields": []any{
|
||||
map[string]any{"name": "body", "fieldDataType": "string", "fieldContext": "log"},
|
||||
map[string]any{"name": "severity_text", "fieldDataType": "string", "fieldContext": "log"},
|
||||
},
|
||||
}
|
||||
|
||||
panel := convertListWidget(widget)
|
||||
require.NotNil(t, panel)
|
||||
assert.Equal(t, PanelKindList, panel.Spec.Plugin.Kind)
|
||||
|
||||
spec, ok := panel.Spec.Plugin.Spec.(*ListPanelSpec)
|
||||
require.True(t, ok)
|
||||
require.Len(t, spec.SelectFields, 2)
|
||||
assert.Equal(t, "body", spec.SelectFields[0].Name)
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Query translation
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func TestConvertV1WidgetQuerySinglePromQL(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "p-1",
|
||||
"panelTypes": "graph",
|
||||
"query": map[string]any{
|
||||
"queryType": "promql",
|
||||
"promql": []any{
|
||||
map[string]any{"name": "A", "query": "up", "legend": "{{job}}"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
queries := convertV1WidgetQuery(widget, PanelKindTimeSeries)
|
||||
require.Len(t, queries, 1)
|
||||
assert.Equal(t, qb.RequestTypeTimeSeries, queries[0].Kind)
|
||||
assert.Equal(t, QueryKindPromQL, queries[0].Spec.Plugin.Kind)
|
||||
|
||||
prom, ok := queries[0].Spec.Plugin.Spec.(*qb.PromQuery)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "A", prom.Name)
|
||||
assert.Equal(t, "up", prom.Query)
|
||||
assert.Equal(t, "{{job}}", prom.Legend)
|
||||
}
|
||||
|
||||
func TestConvertV1WidgetQuerySingleClickHouse(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "c-1",
|
||||
"panelTypes": "table",
|
||||
"query": map[string]any{
|
||||
"queryType": "clickhouse_sql",
|
||||
"clickhouse_sql": []any{
|
||||
map[string]any{"name": "Q", "query": "SELECT 1", "legend": "x"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
queries := convertV1WidgetQuery(widget, PanelKindTable)
|
||||
require.Len(t, queries, 1)
|
||||
assert.Equal(t, qb.RequestTypeScalar, queries[0].Kind)
|
||||
assert.Equal(t, QueryKindClickHouseSQL, queries[0].Spec.Plugin.Kind)
|
||||
|
||||
ch, ok := queries[0].Spec.Plugin.Spec.(*qb.ClickHouseQuery)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "Q", ch.Name)
|
||||
assert.Equal(t, "SELECT 1", ch.Query)
|
||||
}
|
||||
|
||||
func TestConvertV1WidgetQueryMultipleBuilderWrapsInComposite(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "b-1",
|
||||
"panelTypes": "graph",
|
||||
"query": map[string]any{
|
||||
"queryType": "builder",
|
||||
"builder": map[string]any{
|
||||
"queryData": []any{
|
||||
map[string]any{
|
||||
"queryName": "A",
|
||||
"expression": "A",
|
||||
"dataSource": "metrics",
|
||||
"aggregations": []any{map[string]any{"metricName": "signoz_calls_total"}},
|
||||
},
|
||||
map[string]any{
|
||||
"queryName": "B",
|
||||
"expression": "B",
|
||||
"dataSource": "logs",
|
||||
"aggregations": []any{map[string]any{"expression": "count()"}},
|
||||
},
|
||||
},
|
||||
"queryFormulas": []any{
|
||||
map[string]any{"queryName": "F1", "expression": "A + B"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
queries := convertV1WidgetQuery(widget, PanelKindTimeSeries)
|
||||
require.Len(t, queries, 1)
|
||||
assert.Equal(t, qb.RequestTypeTimeSeries, queries[0].Kind)
|
||||
assert.Equal(t, QueryKindComposite, queries[0].Spec.Plugin.Kind)
|
||||
|
||||
composite, ok := queries[0].Spec.Plugin.Spec.(*CompositeQuerySpec)
|
||||
require.True(t, ok)
|
||||
require.Len(t, composite.Queries, 3)
|
||||
assert.Equal(t, qb.QueryTypeBuilder, composite.Queries[0].Type)
|
||||
assert.Equal(t, qb.QueryTypeBuilder, composite.Queries[1].Type)
|
||||
assert.Equal(t, qb.QueryTypeFormula, composite.Queries[2].Type)
|
||||
}
|
||||
|
||||
func TestConvertV1WidgetQueryListPanelUsesBuilderDirectly(t *testing.T) {
|
||||
widget := map[string]any{
|
||||
"id": "l-1",
|
||||
"panelTypes": "list",
|
||||
"query": map[string]any{
|
||||
"queryType": "builder",
|
||||
"builder": map[string]any{
|
||||
"queryData": []any{
|
||||
map[string]any{
|
||||
"queryName": "A",
|
||||
"expression": "A",
|
||||
"dataSource": "logs",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
queries := convertV1WidgetQuery(widget, PanelKindList)
|
||||
require.Len(t, queries, 1)
|
||||
assert.Equal(t, qb.RequestTypeRaw, queries[0].Kind)
|
||||
assert.Equal(t, QueryKindBuilder, queries[0].Spec.Plugin.Kind)
|
||||
|
||||
wrapper, ok := queries[0].Spec.Plugin.Spec.(*BuilderQuerySpec)
|
||||
require.True(t, ok)
|
||||
spec, ok := wrapper.Spec.(qb.QueryBuilderQuery[qb.LogAggregation])
|
||||
require.True(t, ok, "list builder query should dispatch to LogAggregation, got %T", wrapper.Spec)
|
||||
assert.Equal(t, "A", spec.Name)
|
||||
}
|
||||
|
||||
func TestConvertV1WidgetQueryNoQuery(t *testing.T) {
|
||||
widget := map[string]any{"id": "x", "panelTypes": "graph"}
|
||||
queries := convertV1WidgetQuery(widget, PanelKindTimeSeries)
|
||||
assert.Nil(t, queries)
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Layouts and sections
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func TestConvertV1LayoutsRootOnly(t *testing.T) {
|
||||
data := StorableDashboardData{
|
||||
"layout": []any{
|
||||
map[string]any{"i": "p-1", "x": float64(0), "y": float64(0), "w": float64(6), "h": float64(6)},
|
||||
map[string]any{"i": "p-2", "x": float64(6), "y": float64(0), "w": float64(6), "h": float64(6)},
|
||||
},
|
||||
"widgets": []any{
|
||||
map[string]any{"id": "p-1", "panelTypes": "graph"},
|
||||
map[string]any{"id": "p-2", "panelTypes": "graph"},
|
||||
},
|
||||
}
|
||||
|
||||
layouts := convertV1Layouts(data)
|
||||
require.Len(t, layouts, 1)
|
||||
assert.Equal(t, dashboard.KindGridLayout, layouts[0].Kind)
|
||||
|
||||
spec, ok := layouts[0].Spec.(*dashboard.GridLayoutSpec)
|
||||
require.True(t, ok)
|
||||
require.Len(t, spec.Items, 2)
|
||||
assert.Equal(t, "#/spec/panels/p-1", spec.Items[0].Content.Ref)
|
||||
assert.Equal(t, 6, spec.Items[1].Width)
|
||||
assert.Nil(t, spec.Display, "root-only grid should have no display block")
|
||||
}
|
||||
|
||||
func TestConvertV1LayoutsWithCollapsedSection(t *testing.T) {
|
||||
data := StorableDashboardData{
|
||||
"widgets": []any{
|
||||
map[string]any{"id": "row-1", "panelTypes": "row", "title": "Latency"},
|
||||
map[string]any{"id": "p-1", "panelTypes": "graph"},
|
||||
map[string]any{"id": "p-2", "panelTypes": "graph"},
|
||||
},
|
||||
"layout": []any{
|
||||
map[string]any{"i": "row-1", "x": float64(0), "y": float64(0), "w": float64(12), "h": float64(1)},
|
||||
map[string]any{"i": "p-1", "x": float64(0), "y": float64(1), "w": float64(6), "h": float64(6)},
|
||||
map[string]any{"i": "p-2", "x": float64(0), "y": float64(7), "w": float64(6), "h": float64(6)},
|
||||
},
|
||||
"panelMap": map[string]any{
|
||||
"row-1": map[string]any{
|
||||
"collapsed": true,
|
||||
"widgets": []any{
|
||||
map[string]any{"i": "p-1", "x": float64(0), "y": float64(1), "w": float64(6), "h": float64(6)},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
layouts := convertV1Layouts(data)
|
||||
require.Len(t, layouts, 2, "one root grid (p-2) + one section grid (row-1 with p-1)")
|
||||
|
||||
rootSpec, ok := layouts[0].Spec.(*dashboard.GridLayoutSpec)
|
||||
require.True(t, ok)
|
||||
require.Len(t, rootSpec.Items, 1)
|
||||
assert.Equal(t, "#/spec/panels/p-2", rootSpec.Items[0].Content.Ref)
|
||||
assert.Nil(t, rootSpec.Display)
|
||||
|
||||
sectionSpec, ok := layouts[1].Spec.(*dashboard.GridLayoutSpec)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, sectionSpec.Display)
|
||||
assert.Equal(t, "Latency", sectionSpec.Display.Title)
|
||||
require.NotNil(t, sectionSpec.Display.Collapse)
|
||||
assert.False(t, sectionSpec.Display.Collapse.Open, "collapsed=true → open=false")
|
||||
require.Len(t, sectionSpec.Items, 1)
|
||||
assert.Equal(t, "#/spec/panels/p-1", sectionSpec.Items[0].Content.Ref)
|
||||
}
|
||||
|
||||
func TestConvertV1LayoutsEmpty(t *testing.T) {
|
||||
assert.Nil(t, convertV1Layouts(StorableDashboardData{}))
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Variables
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
func TestConvertV1VariablesAllTypes(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"u-1": map[string]any{
|
||||
"name": "service.name",
|
||||
"description": "the service",
|
||||
"type": "QUERY",
|
||||
"queryValue": "SELECT name FROM s",
|
||||
"multiSelect": true,
|
||||
"showALLOption": true,
|
||||
"sort": "ASC",
|
||||
"order": float64(1),
|
||||
},
|
||||
"u-2": map[string]any{
|
||||
"name": "env",
|
||||
"type": "CUSTOM",
|
||||
"customValue": "prod,staging,dev",
|
||||
"order": float64(2),
|
||||
"selectedValue": "prod",
|
||||
},
|
||||
"u-3": map[string]any{
|
||||
"name": "deployment.environment",
|
||||
"type": "DYNAMIC",
|
||||
"dynamicVariablesAttribute": "deployment.environment",
|
||||
"dynamicVariablesSource": "traces",
|
||||
"order": float64(0),
|
||||
},
|
||||
"u-4": map[string]any{
|
||||
"name": "freetext",
|
||||
"type": "TEXTBOX",
|
||||
"textboxValue": "hello",
|
||||
"order": float64(3),
|
||||
},
|
||||
}
|
||||
|
||||
vars := convertV1Variables(raw)
|
||||
require.Len(t, vars, 4)
|
||||
|
||||
// Ordered by `order` ascending: u-3 (0), u-1 (1), u-2 (2), u-4 (3)
|
||||
assert.Equal(t, variable.KindList, vars[0].Kind)
|
||||
dyn, ok := vars[0].Spec.(*ListVariableSpec)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "deployment.environment", dyn.Name)
|
||||
assert.Equal(t, VariableKindDynamic, dyn.Plugin.Kind)
|
||||
|
||||
q, ok := vars[1].Spec.(*ListVariableSpec)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "service.name", q.Name)
|
||||
assert.Equal(t, VariableKindQuery, q.Plugin.Kind)
|
||||
assert.True(t, q.AllowMultiple)
|
||||
assert.True(t, q.AllowAllValue)
|
||||
require.NotNil(t, q.Sort)
|
||||
assert.Equal(t, variable.SortAlphabeticalAsc, *q.Sort)
|
||||
|
||||
c, ok := vars[2].Spec.(*ListVariableSpec)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "env", c.Name)
|
||||
assert.Equal(t, VariableKindCustom, c.Plugin.Kind)
|
||||
require.NotNil(t, c.DefaultValue)
|
||||
assert.Equal(t, "prod", c.DefaultValue.SingleValue)
|
||||
|
||||
assert.Equal(t, variable.KindText, vars[3].Kind)
|
||||
text, ok := vars[3].Spec.(*dashboard.TextVariableSpec)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "freetext", text.Name)
|
||||
assert.Equal(t, "hello", text.Value)
|
||||
}
|
||||
|
||||
func TestConvertV1VariablesSkipsUnnamedAndUnknownTypes(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"u-1": map[string]any{"name": "", "type": "QUERY"},
|
||||
"u-2": map[string]any{"name": "ok", "type": "WHATEVER"},
|
||||
"u-3": map[string]any{"name": "good", "type": "CUSTOM", "customValue": "a"},
|
||||
}
|
||||
vars := convertV1Variables(raw)
|
||||
require.Len(t, vars, 1)
|
||||
spec := vars[0].Spec.(*ListVariableSpec)
|
||||
assert.Equal(t, "good", spec.Name)
|
||||
}
|
||||
|
||||
func TestConvertV1VariablesDefaultFromSelectedSlice(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"u-1": map[string]any{
|
||||
"name": "svc",
|
||||
"type": "QUERY",
|
||||
"queryValue": "SELECT 1",
|
||||
"selectedValue": []any{"foo", "", "bar"},
|
||||
},
|
||||
}
|
||||
vars := convertV1Variables(raw)
|
||||
require.Len(t, vars, 1)
|
||||
spec := vars[0].Spec.(*ListVariableSpec)
|
||||
require.NotNil(t, spec.DefaultValue)
|
||||
assert.Equal(t, []string{"foo", "bar"}, spec.DefaultValue.SliceValues)
|
||||
}
|
||||
170
pkg/types/dashboardtypes/perses_v1_to_v2_variables.go
Normal file
170
pkg/types/dashboardtypes/perses_v1_to_v2_variables.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
"github.com/perses/spec/go/dashboard/variable"
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Variables
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// convertV1Variables walks the v1 `variables` map (UUID-keyed) and produces an
|
||||
// ordered []Variable. Variables sort by `order` first, then by id for stable
|
||||
// output. v1 variable types map as follows:
|
||||
//
|
||||
// QUERY → ListVariable + signoz/QueryVariable
|
||||
// CUSTOM → ListVariable + signoz/CustomVariable
|
||||
// DYNAMIC → ListVariable + signoz/DynamicVariable
|
||||
// TEXTBOX → TextVariable
|
||||
func convertV1Variables(raw any) []Variable {
|
||||
rawMap, ok := raw.(map[string]any)
|
||||
if !ok || len(rawMap) == 0 {
|
||||
return nil
|
||||
}
|
||||
type ordered struct {
|
||||
key string
|
||||
val map[string]any
|
||||
ord float64
|
||||
}
|
||||
entries := make([]ordered, 0, len(rawMap))
|
||||
for key, value := range rawMap {
|
||||
m, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ord, _ := m["order"].(float64)
|
||||
entries = append(entries, ordered{key: key, val: m, ord: ord})
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
if entries[i].ord != entries[j].ord {
|
||||
return entries[i].ord < entries[j].ord
|
||||
}
|
||||
return entries[i].key < entries[j].key
|
||||
})
|
||||
|
||||
out := make([]Variable, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
v, ok := convertV1Variable(e.val)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func convertV1Variable(v map[string]any) (Variable, bool) {
|
||||
name, _ := v["name"].(string)
|
||||
if name == "" {
|
||||
return Variable{}, false
|
||||
}
|
||||
description, _ := v["description"].(string)
|
||||
kind, _ := v["type"].(string)
|
||||
|
||||
switch kind {
|
||||
case "TEXTBOX":
|
||||
value, _ := v["textboxValue"].(string)
|
||||
spec := &dashboard.TextVariableSpec{
|
||||
TextSpec: variable.TextSpec{
|
||||
Display: &variable.Display{Name: name, Description: description},
|
||||
Value: value,
|
||||
},
|
||||
Name: name,
|
||||
}
|
||||
return Variable{Kind: variable.KindText, Spec: spec}, true
|
||||
|
||||
case "QUERY", "CUSTOM", "DYNAMIC":
|
||||
listSpec := &ListVariableSpec{
|
||||
Display: Display{Name: name, Description: description},
|
||||
AllowAllValue: valueAt[bool](v, "showALLOption"),
|
||||
AllowMultiple: valueAt[bool](v, "multiSelect"),
|
||||
CustomAllValue: valueAt[string](v, "customAllValue"),
|
||||
CapturingRegexp: valueAt[string](v, "capturingRegexp"),
|
||||
Sort: mapV1Sort(v["sort"]),
|
||||
Plugin: variablePluginFor(kind, v),
|
||||
Name: name,
|
||||
}
|
||||
if dv := mapV1VariableDefault(v); dv != nil {
|
||||
listSpec.DefaultValue = dv
|
||||
}
|
||||
return Variable{Kind: variable.KindList, Spec: listSpec}, true
|
||||
}
|
||||
|
||||
return Variable{}, false
|
||||
}
|
||||
|
||||
func variablePluginFor(kind string, v map[string]any) VariablePlugin {
|
||||
switch kind {
|
||||
case "QUERY":
|
||||
return VariablePlugin{
|
||||
Kind: VariableKindQuery,
|
||||
Spec: &QueryVariableSpec{QueryValue: valueAt[string](v, "queryValue")},
|
||||
}
|
||||
case "CUSTOM":
|
||||
return VariablePlugin{
|
||||
Kind: VariableKindCustom,
|
||||
Spec: &CustomVariableSpec{CustomValue: valueAt[string](v, "customValue")},
|
||||
}
|
||||
case "DYNAMIC":
|
||||
spec := &DynamicVariableSpec{Name: valueAt[string](v, "dynamicVariablesAttribute")}
|
||||
if signal := signalFromDataSource(v["dynamicVariablesSource"]); !signal.IsZero() {
|
||||
spec.Signal = signal
|
||||
}
|
||||
return VariablePlugin{Kind: VariableKindDynamic, Spec: spec}
|
||||
}
|
||||
return VariablePlugin{}
|
||||
}
|
||||
|
||||
func mapV1VariableDefault(v map[string]any) *variable.DefaultValue {
|
||||
if raw, ok := v["selectedValue"]; ok {
|
||||
return defaultValueFromAny(raw)
|
||||
}
|
||||
if raw, ok := v["defaultValue"]; ok {
|
||||
return defaultValueFromAny(raw)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultValueFromAny(raw any) *variable.DefaultValue {
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return &variable.DefaultValue{SingleValue: v}
|
||||
case []any:
|
||||
if len(v) == 0 {
|
||||
return nil
|
||||
}
|
||||
values := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok && s != "" {
|
||||
values = append(values, s)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &variable.DefaultValue{SliceValues: values}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapV1Sort(raw any) *variable.Sort {
|
||||
s, _ := raw.(string)
|
||||
var sort variable.Sort
|
||||
switch s {
|
||||
case "ASC":
|
||||
sort = variable.SortAlphabeticalAsc
|
||||
case "DESC":
|
||||
sort = variable.SortAlphabeticalDesc
|
||||
case "DISABLED", "":
|
||||
return nil // SortNone is the implicit default
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return &sort
|
||||
}
|
||||
66
pkg/types/migration.md
Normal file
66
pkg/types/migration.md
Normal file
@@ -0,0 +1,66 @@
|
||||
### Phases
|
||||
|
||||
1. **Pre-migration (dev)**: new tables `tag`, `tag_relations`, `pinned_dashboard`, `dashboard_view`
|
||||
2. **Validation**: run the migration script against a few prod snapshots locally. Verify counts match, spot-check shapes, time the run to estimate downtime.
|
||||
3. **Dry-run in cloud prod (cloud only).** Ship a build that runs the migration script in read-only
|
||||
mode against live prod data. Whenever the v1 get API is called for a dashboard, we dry-run the migration script for it in an async process. If there is a failure, schema mismatches, tag normalization rejections, etc, it is logged. Reach out to affected customers to fix their dashboards before the real migration. Re-run closer to migration day to confirm resolution.
|
||||
4. **Migration deploy**: script runs, FF flips on. Integration dashboards materialized in the `dashboard` table using an internal system account with `Locked = true`.
|
||||
5. **Post-migration**: v1 APIs deprecated but still respond.
|
||||
|
||||
#### **Rejected idea: dry run in a background job**
|
||||
|
||||
In the above plan, we only check the dashboards that the users access. However, that should be enough to cover enough dashboards to be able to find out possible issues. The extra effort of a background job doesn't have enough ROI.
|
||||
|
||||
### What gets migrated
|
||||
|
||||
Existing v1 dashboards → full v2 data shape (tags extracted from `data.tags` into `tag` and `tag_relations`; the field is removed from the blob). Integration dashboards → materialized rows. Pinned dashboards and saved views start empty.
|
||||
|
||||
### Tag normalization (v1 strings → v2 tag rows)
|
||||
|
||||
Each v1 dashboard `data.tags` is `[]string`. For every string `s`, derive `(key, value)`.
|
||||
|
||||
**Order of rules:**
|
||||
|
||||
1. **Trim** leading/trailing whitespace from `s`. If empty after trim → **skip silently** (log dashboard id + index, continue).
|
||||
2. **If `s` contains `:`** → split at the **first** `:`. Let `k` = left side, `v` = right side.
|
||||
- If `k` is empty (input was `:val`) → `key = "tag"`, `value = val`.
|
||||
- If `v` is empty (input was `key:`) → `key = "tag"`, `value = k` (the literal left side becomes the value).
|
||||
- Otherwise → `key = k`, `value = v`.
|
||||
- Other `:` are replaced with `_`.
|
||||
3. **Else if `s` contains `/`** → split at the **first** `/`. Let `k` = left side, `v` = right side.
|
||||
- Same empty-side handling: empty left → `key="tag", value=v`; empty right → `key="tag", value=k`. Otherwise → `key=k, value=v`.
|
||||
4. **Else** (no separator) → `key = "tag"`, `value = s`.
|
||||
5. Reserved-key collision. After steps 2–4, if the resulting key (case-insensitively) matches a reserved DSL key (name, description, created_at, updated_at, created_by, locked, public), prefix it with _ (e.g. name → _name). Silent — extremely unlikely in practice, but the rename keeps the dashboard alive without ambiguating the query DSL.
|
||||
6. **`/` scrub.** Output tags must never contain `/` (input validation forbids it). After the above steps, replace any remaining `/` in `key` and `value` with `_`:
|
||||
- `a/b/c` → step 3 splits at first `/` → `key="a", value="b/c"` → after scrub → `key="a", value="b_c"`
|
||||
- `team/eng:prod` → step 2 splits at `:` → `key="team/eng", value="prod"` → after scrub → `key="team_eng", value="prod"`
|
||||
- `team/eng:my/path` → step 2 → `key="team/eng", value="my/path"` → scrub → `key="team_eng", value="my_path"`
|
||||
|
||||
Trailing/leading whitespace within `key` and `value` after split is also trimmed; if either side becomes empty after that, apply the empty-side rules above. If both sides are effectively empty (e.g. input was `:` or `/`), skip silently.
|
||||
|
||||
**Case-collision dedup:**
|
||||
|
||||
Multiple v1 strings can normalize to the same `(LOWER(key), LOWER(value))` across an org (e.g. `Env:Prod` and `env:PROD`). The functional unique index ensures only one row exists. Display casing is taken from the variant on the dashboard with the **earliest `created_at`** (ties broken by `dashboard.id`) — same rule as the previous spec, just applied to `(key, value)` instead of `name`.
|
||||
|
||||
**Tag relations:**
|
||||
|
||||
After tag rows are upserted, build `tag_relations` from each (dashboard, tag-id-after-dedup) pair. `ON CONFLICT` clause in the query makes this idempotent.
|
||||
|
||||
### Script properties
|
||||
|
||||
- Per-dashboard transactional. One failure logs the dashboard id and continues.
|
||||
- Idempotent: `ON CONFLICT DO NOTHING` on tag and tag_relations upserts; dashboards already in v2 shape are skipped.
|
||||
- Progress logged every N dashboards; final summary includes totals and failure list.
|
||||
|
||||
### Rollback
|
||||
|
||||
Forward-only — no v2→v1 reverse script. The FF is the kill-switch pre-frontend-cutover. After cutover, rollback = another deploy with the fix.
|
||||
|
||||
### What about dashboards that fail to migrate after all this?
|
||||
|
||||
In Get API (v2) there will be a check on the dashboard fetched.
|
||||
|
||||
- `v2` → normal flow.
|
||||
- `v1` → return `422 Unprocessable Entity`.
|
||||
|
||||
The deprecated v1 APIs will still exist, so if any support ticket comes, we can check via the v1 API and see what’s wrong.
|
||||
Reference in New Issue
Block a user