mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-27 22:00:41 +01:00
Compare commits
19 Commits
issue_6107
...
feat/new-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61a6fb5d1f | ||
|
|
c4a6ce85fa | ||
|
|
45a062babc | ||
|
|
276056c4ab | ||
|
|
ffbba01e29 | ||
|
|
01f7f6869a | ||
|
|
de3c3c268f | ||
|
|
9a602d015a | ||
|
|
3b6becff7a | ||
|
|
ab715533b9 | ||
|
|
8e2da68fc6 | ||
|
|
8371a70801 | ||
|
|
9d9b0e194a | ||
|
|
2a7f4fd603 | ||
|
|
ee35fc351f | ||
|
|
720810d424 | ||
|
|
7424885a14 | ||
|
|
2c09fedde1 | ||
|
|
5a1be60745 |
11
.github/CODEOWNERS
vendored
11
.github/CODEOWNERS
vendored
@@ -200,6 +200,15 @@ go.mod @therealpandey
|
||||
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/RoutingPolicies/ @SigNoz/pulse-frontend
|
||||
/frontend/src/components/AlertBreadcrumb/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/EditRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/components/AlertDetailsFilters/ @SigNoz/pulse-frontend
|
||||
/frontend/src/components/Alerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/routingPolicies/ @SigNoz/pulse-frontend
|
||||
/frontend/src/types/api/alerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/providers/Alert.tsx @SigNoz/pulse-frontend
|
||||
/frontend/src/constants/alerts.ts @SigNoz/pulse-frontend
|
||||
|
||||
## Notification Channels
|
||||
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
|
||||
@@ -207,6 +216,8 @@ go.mod @therealpandey
|
||||
/frontend/src/container/AllAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/EditAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/FormAlertChannels/ @SigNoz/pulse-frontend
|
||||
/frontend/src/hooks/notificationChannels/ @SigNoz/pulse-frontend
|
||||
|
||||
## OpenAPI Schema - Generated
|
||||
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
|
||||
|
||||
2
.github/workflows/integrationci.yaml
vendored
2
.github/workflows/integrationci.yaml
vendored
@@ -38,7 +38,6 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suite:
|
||||
- alerts
|
||||
- alertmanager
|
||||
- alertmanagerrotation
|
||||
- basepath
|
||||
@@ -64,6 +63,7 @@ jobs:
|
||||
- querierauthz
|
||||
- role
|
||||
- rootuser
|
||||
- ruler
|
||||
- savedview
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -179,6 +179,7 @@ The `handler.New` function ties the HTTP handler to OpenAPI metadata via `OpenAP
|
||||
- **SuccessStatusCode**: The HTTP status for successful responses (for example, `http.StatusOK`, `http.StatusCreated`, `http.StatusNoContent`).
|
||||
- **ErrorStatusCodes**: Additional error status codes beyond the standard ones automatically added by `handler.New`.
|
||||
- **SecuritySchemes**: Auth mechanisms and scopes required by the operation.
|
||||
- **Stability**: Maturity marker (`handler.StabilityDevelopment`, `handler.StabilityAlpha`, `handler.StabilityBeta`, `handler.StabilityStable`, the OpenTelemetry Collector levels) emitted as the `x-signoz-stability` extension on every operation. Unset is emitted as `alpha`.
|
||||
|
||||
The generic handler:
|
||||
|
||||
|
||||
@@ -23,6 +23,15 @@ func (f *formatter) JSONExtractString(column, path string) []byte {
|
||||
return append(f.TextToJsonColumn(column), ops...)
|
||||
}
|
||||
|
||||
func (f *formatter) JSONExtractMapValue(column, mapField, key string) []byte {
|
||||
sql := f.TextToJsonColumn(column)
|
||||
sql = append(sql, "->"...)
|
||||
sql = schema.Append(f.bunf, sql, mapField)
|
||||
sql = append(sql, "->>"...)
|
||||
sql = schema.Append(f.bunf, sql, key)
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONType(column, path string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "jsonb_typeof("...)
|
||||
|
||||
@@ -55,6 +55,67 @@ func TestJSONExtractString(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONExtractMapValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
mapField string
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "PlainKey",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: "team",
|
||||
expected: `"data"::jsonb->'labels'->>'team'`,
|
||||
},
|
||||
{
|
||||
name: "DottedKey_OneMapEntry",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: "k8s.cluster",
|
||||
expected: `"data"::jsonb->'labels'->>'k8s.cluster'`,
|
||||
},
|
||||
{
|
||||
name: "SingleQuoteInKey_Doubled",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: "o'brien",
|
||||
expected: `"data"::jsonb->'labels'->>'o''brien'`,
|
||||
},
|
||||
{
|
||||
name: "BackslashInKey_Literal",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: `a\b`,
|
||||
expected: `"data"::jsonb->'labels'->>'a\b'`,
|
||||
},
|
||||
{
|
||||
name: "DoubleQuoteInKey_Literal",
|
||||
column: "data",
|
||||
mapField: "labels",
|
||||
key: `a"b`,
|
||||
expected: `"data"::jsonb->'labels'->>'a"b'`,
|
||||
},
|
||||
{
|
||||
name: "QualifiedColumn",
|
||||
column: "rule.data",
|
||||
mapField: "labels",
|
||||
key: "severity",
|
||||
expected: `"rule"."data"::jsonb->'labels'->>'severity'`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got := string(f.JSONExtractMapValue(tt.column, tt.mapField, tt.key))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -41,6 +41,8 @@ import type {
|
||||
GetRuleHistoryTopContributorsParams,
|
||||
GetRuleHistoryTopContributorsPathParameters,
|
||||
ListRules200,
|
||||
ListRulesV3200,
|
||||
ListRulesV3Params,
|
||||
PatchRuleByID200,
|
||||
PatchRuleByIDPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -73,7 +75,8 @@ const withQueryKey = <T extends object, K>(
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists all alert rules with their current evaluation state
|
||||
* This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.
|
||||
* @deprecated
|
||||
* @summary List alert rules
|
||||
*/
|
||||
export const listRules = (signal?: AbortSignal) => {
|
||||
@@ -115,6 +118,7 @@ export type ListRulesQueryResult = NonNullable<
|
||||
export type ListRulesQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List alert rules
|
||||
*/
|
||||
|
||||
@@ -134,6 +138,7 @@ export function useListRules<
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary List alert rules
|
||||
*/
|
||||
export const invalidateListRules = async (
|
||||
@@ -1388,3 +1393,97 @@ export const useTestRule = <
|
||||
> => {
|
||||
return useMutation(getTestRuleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a page of alert rules with their current evaluation state, trimmed to the fields the list page renders. Supports a filter DSL (`query`), a repeated `states` filter applied after the state overlay, sort (`updated_at`/`created_at`/`name`/`state`/`severity`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). In the filter DSL, a non-reserved key is matched as a rule label directly (`team = infra`); a key that collides with a reserved keyword matches either interpretation (negative operators exclude both), and `labels.<key>` targets only the label. The response also carries the org's label pairs and the reserved filter keys for building filter suggestions.
|
||||
* @summary List alert rules (v3)
|
||||
*/
|
||||
export const listRulesV3 = (
|
||||
params?: ListRulesV3Params,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListRulesV3200>({
|
||||
url: `/api/v3/rules`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListRulesV3QueryKey = (params?: ListRulesV3Params) => {
|
||||
return [`/api/v3/rules`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListRulesV3QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListRulesV3Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListRulesV3QueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listRulesV3>>> = ({
|
||||
signal,
|
||||
}) => listRulesV3(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListRulesV3QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listRulesV3>>
|
||||
>;
|
||||
export type ListRulesV3QueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List alert rules (v3)
|
||||
*/
|
||||
|
||||
export function useListRulesV3<
|
||||
TData = Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListRulesV3Params,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listRulesV3>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListRulesV3QueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List alert rules (v3)
|
||||
*/
|
||||
export const invalidateListRulesV3 = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListRulesV3Params,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListRulesV3QueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
@@ -10188,6 +10188,99 @@ export interface RuletypesGettableTestRuleDTO {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface RuletypesLabelPairDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
key: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
export enum RuletypesListOrderDTO {
|
||||
asc = 'asc',
|
||||
desc = 'desc',
|
||||
}
|
||||
export enum RuletypesListSortDTO {
|
||||
updated_at = 'updated_at',
|
||||
created_at = 'created_at',
|
||||
name = 'name',
|
||||
state = 'state',
|
||||
severity = 'severity',
|
||||
}
|
||||
export type RuletypesListableRuleDTOLabels = { [key: string]: string };
|
||||
|
||||
export enum RuletypesRuleTypeDTO {
|
||||
threshold_rule = 'threshold_rule',
|
||||
promql_rule = 'promql_rule',
|
||||
anomaly_rule = 'anomaly_rule',
|
||||
}
|
||||
export interface RuletypesListableRuleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
alert: string;
|
||||
alertType: RuletypesAlertTypeDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
labels?: RuletypesListableRuleDTOLabels;
|
||||
ruleType: RuletypesRuleTypeDTO;
|
||||
state: RuletypesAlertStateDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface RuletypesListableRulesDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
labels: RuletypesLabelPairDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
reservedKeywords: string[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
rules: RuletypesListableRuleDTO[];
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface RuletypesRenotifyDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -10284,11 +10377,6 @@ export interface RuletypesRuleConditionDTO {
|
||||
thresholds?: RuletypesRuleThresholdDataDTO;
|
||||
}
|
||||
|
||||
export enum RuletypesRuleTypeDTO {
|
||||
threshold_rule = 'threshold_rule',
|
||||
promql_rule = 'promql_rule',
|
||||
anomaly_rule = 'anomaly_rule',
|
||||
}
|
||||
export interface RuletypesPostableRuleDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -14189,6 +14277,45 @@ export type GetMetricDashboardsV2200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListRulesV3Params = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
query?: string;
|
||||
/**
|
||||
* @type array
|
||||
* @description undefined
|
||||
*/
|
||||
states?: string[];
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
sort?: RuletypesListSortDTO;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
order?: RuletypesListOrderDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ListRulesV3200 = {
|
||||
data: RuletypesListableRulesDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetFlamegraphPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
.quick-filters-settings-container {
|
||||
flex: 0 0 0;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// The one `overflow: hidden` in the chain. Ancestors (RouteTab, AppLayout)
|
||||
// only hand height down; each pane below owns its own scroll.
|
||||
.layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Positioned so overlays (settings drawer) paint above the content pane
|
||||
// without changing this pane's layout width.
|
||||
.filters {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
// Bounded box for the OverlayScrollbar inside it (`.overlay-scrollbar` is
|
||||
// `height: 100%`), which owns the scrolling.
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ComponentProps, ReactNode } from 'react';
|
||||
import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
|
||||
import styles from './QuickFiltersLayout.module.scss';
|
||||
|
||||
// Same optionality as `<QuickFilters />` in JSX (honours its defaultProps).
|
||||
type QuickFiltersElementProps = JSX.LibraryManagedAttributes<
|
||||
typeof QuickFilters,
|
||||
ComponentProps<typeof QuickFilters>
|
||||
>;
|
||||
|
||||
export interface QuickFiltersLayoutProps {
|
||||
quickFilterProps: QuickFiltersElementProps;
|
||||
showFilters: boolean;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
testId?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function QuickFiltersLayout({
|
||||
quickFilterProps,
|
||||
showFilters,
|
||||
className,
|
||||
contentClassName,
|
||||
testId,
|
||||
children,
|
||||
}: QuickFiltersLayoutProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.layout, className)} data-testid={testId}>
|
||||
{showFilters && (
|
||||
<aside
|
||||
className={styles.filters}
|
||||
data-testid="quick-filters-layout-filters"
|
||||
>
|
||||
<QuickFilters {...quickFilterProps} />
|
||||
</aside>
|
||||
)}
|
||||
<section
|
||||
className={cx(styles.content, contentClassName)}
|
||||
data-testid="quick-filters-layout-content"
|
||||
>
|
||||
<OverlayScrollbar>
|
||||
<div>{children}</div>
|
||||
</OverlayScrollbar>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QuickFiltersLayout;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import { QuickFiltersSource } from '../../types';
|
||||
import QuickFiltersLayout from '../QuickFiltersLayout';
|
||||
|
||||
jest.mock('../QuickFiltersLayout.module.scss', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
layout: 'layout',
|
||||
filters: 'filters',
|
||||
content: 'content',
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../../QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: ({ source }: { source: string }): JSX.Element => (
|
||||
<div data-testid="quick-filters">{source}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const quickFilterProps = {
|
||||
source: QuickFiltersSource.TRACES_EXPLORER,
|
||||
handleFilterVisibilityChange: jest.fn(),
|
||||
};
|
||||
|
||||
describe('QuickFiltersLayout', () => {
|
||||
it('renders QuickFilters with the given props inside the filters pane', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters quickFilterProps={quickFilterProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
const filtersPane = screen.getByTestId('quick-filters-layout-filters');
|
||||
expect(filtersPane).toContainElement(screen.getByTestId('quick-filters'));
|
||||
expect(screen.getByTestId('quick-filters')).toHaveTextContent(
|
||||
QuickFiltersSource.TRACES_EXPLORER,
|
||||
);
|
||||
expect(screen.getByTestId('quick-filters-layout-content')).toHaveTextContent(
|
||||
'content',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not render the filters pane when showFilters is false', () => {
|
||||
render(
|
||||
<QuickFiltersLayout showFilters={false} quickFilterProps={quickFilterProps}>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('quick-filters-layout-filters'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('merges classNames onto the root and content panes', () => {
|
||||
render(
|
||||
<QuickFiltersLayout
|
||||
showFilters
|
||||
quickFilterProps={quickFilterProps}
|
||||
className="page-root"
|
||||
contentClassName="page-content"
|
||||
testId="page"
|
||||
>
|
||||
<div>content</div>
|
||||
</QuickFiltersLayout>,
|
||||
);
|
||||
|
||||
const root = screen.getByTestId('page');
|
||||
expect(root).toHaveClass('layout', 'page-root');
|
||||
expect(screen.getByTestId('quick-filters-layout-content')).toHaveClass(
|
||||
'content',
|
||||
'page-content',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,27 +6,12 @@
|
||||
left: 0;
|
||||
z-index: 999;
|
||||
width: 342px;
|
||||
height: 100%;
|
||||
background: var(--l1-background);
|
||||
transition: width 0.05s ease-in-out;
|
||||
overflow: hidden;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
&.qf-logs-explorer {
|
||||
height: calc(100vh - 45px);
|
||||
}
|
||||
|
||||
&.qf-exceptions {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
&.qf-api-monitoring {
|
||||
height: calc(100vh - 45px);
|
||||
}
|
||||
|
||||
&.qf-traces-explorer {
|
||||
height: calc(100vh - 45px);
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
38
frontend/src/components/RouteTab/RouteTab.module.scss
Normal file
38
frontend/src/components/RouteTab/RouteTab.module.scss
Normal file
@@ -0,0 +1,38 @@
|
||||
// Hands the parent's height down to the active pane and lets the pane scroll
|
||||
// its own content, so TopNav and the tab bar stay put. Child combinators only
|
||||
// (nested Tabs must not be caught).
|
||||
.routeTab {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.routeTab > :global(.ant-tabs-content-holder) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.routeTab > :global(.ant-tabs-content-holder) > :global(.ant-tabs-content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.routeTab
|
||||
> :global(.ant-tabs-content-holder)
|
||||
> :global(.ant-tabs-content)
|
||||
> :global(.ant-tabs-tabpane-active) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.routeTab
|
||||
> :global(.ant-tabs-content-holder)
|
||||
> :global(.ant-tabs-content)
|
||||
> :global(.ant-tabs-tabpane-active)
|
||||
> :global(.overlay-scrollbar) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -5,6 +5,11 @@ import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import RouteTab from './index';
|
||||
import { RouteTabProps } from './types';
|
||||
|
||||
jest.mock('./RouteTab.module.scss', () => ({
|
||||
__esModule: true,
|
||||
default: { routeTab: 'routeTab' },
|
||||
}));
|
||||
|
||||
function DummyComponent1(): JSX.Element {
|
||||
return <div>Dummy Component 1</div>;
|
||||
}
|
||||
@@ -74,6 +79,36 @@ describe('RouteTab component', () => {
|
||||
expect(history.location.pathname).toBe('/tab2');
|
||||
});
|
||||
|
||||
it('applies the layout class alongside a custom className', () => {
|
||||
const history = createMemoryHistory();
|
||||
const { container } = render(
|
||||
<Router history={history}>
|
||||
<RouteTab
|
||||
history={history}
|
||||
routes={testRoutes}
|
||||
activeKey="Tab1"
|
||||
className="custom-tabs"
|
||||
/>
|
||||
</Router>,
|
||||
);
|
||||
expect(container.querySelector('.ant-tabs')).toHaveClass(
|
||||
'routeTab',
|
||||
'custom-tabs',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the active tab content inside an overlay scrollbar', () => {
|
||||
const history = createMemoryHistory();
|
||||
const { container } = render(
|
||||
<Router history={history}>
|
||||
<RouteTab history={history} routes={testRoutes} activeKey="Tab1" />
|
||||
</Router>,
|
||||
);
|
||||
expect(
|
||||
container.querySelector('.ant-tabs-tabpane-active > .overlay-scrollbar'),
|
||||
).toHaveTextContent('Dummy Component 1');
|
||||
});
|
||||
|
||||
it('calls onChangeHandler on tab change', () => {
|
||||
const onChangeHandler = jest.fn();
|
||||
const history = createMemoryHistory();
|
||||
|
||||
@@ -5,20 +5,32 @@ import {
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import { Tabs, TabsProps } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
import { RouteTabProps } from './types';
|
||||
|
||||
import styles from './RouteTab.module.scss';
|
||||
|
||||
interface Params {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each pane scrolls its own content inside an OverlayScrollbar, so the tab bar
|
||||
* stays put. Mounted as the page root the pane is bounded to the viewport; inside
|
||||
* a plain block wrapper the scroller is inert and the page scrolls as usual.
|
||||
* Pane content that needs a bounded box must size itself with `height: 100%`
|
||||
* (the scroller's viewport is block flow, so `flex: 1` has no effect there).
|
||||
*/
|
||||
function RouteTab({
|
||||
routes,
|
||||
activeKey,
|
||||
onChangeHandler,
|
||||
history,
|
||||
showRightSection,
|
||||
className,
|
||||
...rest
|
||||
}: RouteTabProps & TabsProps): JSX.Element {
|
||||
const params = useParams<Params>();
|
||||
@@ -50,11 +62,16 @@ function RouteTab({
|
||||
label: name,
|
||||
key,
|
||||
tabKey: route,
|
||||
children: <Component />,
|
||||
children: (
|
||||
<OverlayScrollbar>
|
||||
<Component />
|
||||
</OverlayScrollbar>
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
className={cx(styles.routeTab, className)}
|
||||
onChange={onChange}
|
||||
destroyInactiveTabPane
|
||||
activeKey={currentRoute?.key || activeKey}
|
||||
|
||||
@@ -129,6 +129,10 @@ const themeColors = {
|
||||
salmon2: '#FFAB91',
|
||||
salmon3: '#E0876A',
|
||||
},
|
||||
/* Series palette (dark). Hues in the red band are deliberately absent: red is
|
||||
reserved for thresholds and error states, so an arbitrary series must never
|
||||
claim it. generateColor indexes by `hash % Object.keys(...).length`, so
|
||||
adding or removing an entry recolors every existing chart. */
|
||||
chartcolors: {
|
||||
// Blues (3)
|
||||
dodgerBlue: '#2F80ED',
|
||||
@@ -152,13 +156,13 @@ const themeColors = {
|
||||
|
||||
// Oranges (3)
|
||||
festivalOrange: '#F2994A',
|
||||
coralOrange: '#E17055',
|
||||
amber1: '#E1A155',
|
||||
pumpkin: '#FF7F50',
|
||||
|
||||
// Reds (3)
|
||||
radicalRed: '#FF1A66',
|
||||
crimsonRed: '#EB5757',
|
||||
fireRed: '#E10600',
|
||||
// Olives / Greens (3)
|
||||
olive1: '#DFC33A',
|
||||
olive2: '#D5E55D',
|
||||
green7: '#81C220',
|
||||
|
||||
// Pinks (3)
|
||||
hotPink: '#E84393',
|
||||
@@ -191,9 +195,9 @@ const themeColors = {
|
||||
orange1: '#D35400',
|
||||
orange2: '#E67E22',
|
||||
orange3: '#F5B041',
|
||||
red1: '#C0392B',
|
||||
red2: '#E74C3C',
|
||||
red3: '#EC7063',
|
||||
green8: '#5AC02B',
|
||||
green9: '#48E043',
|
||||
green10: '#68E788',
|
||||
pink1: '#D81B60',
|
||||
pink2: '#E91E63',
|
||||
pink3: '#F06292',
|
||||
@@ -212,9 +216,9 @@ const themeColors = {
|
||||
coral1: '#E67E22',
|
||||
coral2: '#F39C12',
|
||||
coral3: '#F5B041',
|
||||
crimson1: '#C0392B',
|
||||
crimson2: '#E74C3C',
|
||||
crimson3: '#EC7063',
|
||||
teal7: '#2BC07B',
|
||||
teal8: '#43E0C5',
|
||||
teal9: '#68D9E7',
|
||||
violet1: '#8E44AD',
|
||||
violet2: '#9B59B6',
|
||||
violet3: '#BB8FCE',
|
||||
@@ -224,18 +228,18 @@ const themeColors = {
|
||||
forest1: '#27AE60',
|
||||
forest2: '#2ECC71',
|
||||
forest3: '#58D68D',
|
||||
blush1: '#FF6F91',
|
||||
cyan4: '#83C2EB',
|
||||
blush2: '#FF85A2',
|
||||
blush3: '#FFA0B3',
|
||||
lavender1: '#9B59B6',
|
||||
lavender2: '#AF7AC5',
|
||||
lavender3: '#C39BD3',
|
||||
tomato1: '#E74C3C',
|
||||
tomato2: '#EC7063',
|
||||
tomato3: '#F1948A',
|
||||
salmon1: '#FF6B6B',
|
||||
salmon2: '#FF8787',
|
||||
salmon3: '#FFA1A1',
|
||||
blue7: '#4375E0',
|
||||
blue8: '#686DE7',
|
||||
indigo1: '#A68EED',
|
||||
indigo2: '#B980EA',
|
||||
purple6: '#EE98D9',
|
||||
olive3: '#F2F0AE',
|
||||
mustard1: '#F1C40F',
|
||||
mustard2: '#F7DC6F',
|
||||
mustard3: '#F9E79F',
|
||||
@@ -254,9 +258,9 @@ const themeColors = {
|
||||
blue4: '#2874A6',
|
||||
blue5: '#2E86C1',
|
||||
blue6: '#3498DB',
|
||||
red4: '#C0392B',
|
||||
red5: '#E74C3C',
|
||||
red6: '#EC7063',
|
||||
purple4: '#A52BC0',
|
||||
purple5: '#E043D0',
|
||||
magenta4: '#E768B5',
|
||||
orange4: '#D35400',
|
||||
orange5: '#E67E22',
|
||||
orange6: '#EB984E',
|
||||
@@ -267,18 +271,19 @@ const themeColors = {
|
||||
gold5: '#F1C40F',
|
||||
gold6: '#F4D03F',
|
||||
},
|
||||
/* Series palette (light). Same red-free constraint as chartcolors above. */
|
||||
lightModeColor: {
|
||||
radicalRed: '#D81B60',
|
||||
magenta1: '#D81B60',
|
||||
|
||||
dodgerBlueDark: '#1E5BD9',
|
||||
steelgrey: '#344B6B',
|
||||
steelpurple: '#5E548E',
|
||||
steelindigo: '#8E4A7C',
|
||||
steelpink: '#B63A6F',
|
||||
steelcoral: '#E14B5A',
|
||||
amber1: '#E1A14B',
|
||||
steelorange: '#E76F2F',
|
||||
steelgold: '#E09B00',
|
||||
steelrust: '#C93A50',
|
||||
olive1: '#C9BD3A',
|
||||
steelgreen: '#2F7D69',
|
||||
|
||||
mediumOrchidDark: '#8E24AA',
|
||||
@@ -286,17 +291,17 @@ const themeColors = {
|
||||
seaGreen: '#1E7F5A',
|
||||
turquoiseBlueDark: '#007EA7',
|
||||
silverDark: '#5F5F5F',
|
||||
outrageousOrangeDark: '#E64A19',
|
||||
roseBudDark: '#D84315',
|
||||
green1: '#ACDB24',
|
||||
green2: '#66CC21',
|
||||
deepSkyBlueDark: '#0277BD',
|
||||
royalBlue: '#2A4FDB',
|
||||
|
||||
avocadoDark: '#6B6B1E',
|
||||
mintGreenDark: '#2E9E55',
|
||||
chestnut: '#8B3A3A',
|
||||
green3: '#3F8B3A',
|
||||
limaDark: '#5C7F00',
|
||||
olive: '#6E7F00',
|
||||
beautyBushDark: '#C93C3C',
|
||||
green4: '#3CC964',
|
||||
|
||||
danube: '#4F6FB3',
|
||||
oliveDrab: '#4F7F1A',
|
||||
@@ -304,13 +309,13 @@ const themeColors = {
|
||||
electricLimeDark: '#6B8F00',
|
||||
robin: '#2F4FCC',
|
||||
|
||||
harleyOrange: '#CC2E12',
|
||||
teal1: '#1FBF83',
|
||||
gladeGreen: '#4F7F46',
|
||||
hemlock: '#5C5C45',
|
||||
vidaLoca: '#3D6B00',
|
||||
rust: '#993300',
|
||||
|
||||
red: '#C62828',
|
||||
teal2: '#28C6C1',
|
||||
blue: '#1A237E',
|
||||
green: '#1B7F3A',
|
||||
purple: '#6A1B9A',
|
||||
@@ -320,7 +325,7 @@ const themeColors = {
|
||||
brown: '#7A3A1E',
|
||||
teal: '#006D6F',
|
||||
limeDark: '#4C8C2B',
|
||||
maroon: '#6D1B1B',
|
||||
cyan1: '#1B546D',
|
||||
navy: '#0D1B5E',
|
||||
gray: '#616161',
|
||||
|
||||
@@ -328,25 +333,25 @@ const themeColors = {
|
||||
indigo: '#303F9F',
|
||||
slateGray: '#556B7C',
|
||||
chocolate: '#9C4A1A',
|
||||
tomato: '#E53935',
|
||||
blue1: '#3B74DF',
|
||||
steelBlue: '#3A6EA5',
|
||||
|
||||
peruDark: '#B35E00',
|
||||
darkOliveGreen: '#445B1F',
|
||||
indianRed: '#B04040',
|
||||
blue2: '#4041B0',
|
||||
mediumSlateBlue: '#5C6BC0',
|
||||
rosyBrownDark: '#A94444',
|
||||
indigo1: '#6644A9',
|
||||
darkSlateGray: '#2E4A4A',
|
||||
|
||||
fuchsia: '#C511C5',
|
||||
salmonDark: '#E64A3C',
|
||||
darkSalmonDark: '#C85A3A',
|
||||
indigo2: '#AD42E0',
|
||||
purple1: '#C83AC5',
|
||||
paleVioletRedDark: '#C2186A',
|
||||
|
||||
mediumPurple: '#7E57C2',
|
||||
darkOrchid: '#7B1FA2',
|
||||
mediumSeaGreenDark: '#2E8B57',
|
||||
lightCoralDark: '#E57373',
|
||||
purple2: '#E573BC',
|
||||
|
||||
gold: '#D4AF37',
|
||||
sandyBrownDark: '#C76A15',
|
||||
|
||||
@@ -3,15 +3,22 @@ import {
|
||||
MessageActionKindDTO,
|
||||
SavedViewEntityDTO,
|
||||
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
|
||||
import { getAllViews } from 'api/saveView/getAllViews';
|
||||
import { getViewById } from 'api/saveView/getViewById';
|
||||
import {
|
||||
getSavedView,
|
||||
listSavedViews,
|
||||
} from 'api/generated/services/saved-view';
|
||||
import {
|
||||
GetSavedView200,
|
||||
ListSavedViews200,
|
||||
SavedviewtypesPanelTypeDTO,
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
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 {
|
||||
@@ -31,8 +38,7 @@ import {
|
||||
} from '../resolveOpenResource';
|
||||
import { resourceRoute, ResourceType } from '../resourceRoute';
|
||||
|
||||
jest.mock('api/saveView/getAllViews');
|
||||
jest.mock('api/saveView/getViewById');
|
||||
jest.mock('api/generated/services/saved-view');
|
||||
|
||||
jest.mock(
|
||||
'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi',
|
||||
@@ -48,43 +54,45 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
const mockedGetAllViews = getAllViews as jest.MockedFunction<
|
||||
typeof getAllViews
|
||||
const mockedListSavedViews = listSavedViews as jest.MockedFunction<
|
||||
typeof listSavedViews
|
||||
>;
|
||||
const mockedGetViewById = getViewById as jest.MockedFunction<
|
||||
typeof getViewById
|
||||
const mockedGetSavedView = getSavedView as jest.MockedFunction<
|
||||
typeof getSavedView
|
||||
>;
|
||||
|
||||
function makeView(id: string, sourcePage: DataSource): ViewProps {
|
||||
function makeView(
|
||||
id: string,
|
||||
source: SavedviewtypesSourceDTO,
|
||||
): SavedviewtypesSavedViewDTO {
|
||||
return {
|
||||
id,
|
||||
name: `View ${id}`,
|
||||
category: 'test',
|
||||
name: `view-${id}`,
|
||||
source,
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
|
||||
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,
|
||||
};
|
||||
spec: {
|
||||
displayName: `View ${id}`,
|
||||
panelType: SavedviewtypesPanelTypeDTO.list,
|
||||
requestType: 'raw',
|
||||
queries: [{ type: 'builder_query', spec: { name: 'A', signal: source } }],
|
||||
},
|
||||
} as unknown as SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
function mockViewsResponse(views: ViewProps[]): AxiosResponse<AllViewsProps> {
|
||||
return {
|
||||
data: { status: 'success', data: views },
|
||||
} as AxiosResponse<AllViewsProps>;
|
||||
function mockViewsResponse(
|
||||
views: SavedviewtypesSavedViewDTO[],
|
||||
): ListSavedViews200 {
|
||||
return { status: 'success', data: views };
|
||||
}
|
||||
|
||||
function mockViewByIdResponse(
|
||||
view: ViewProps,
|
||||
): AxiosResponse<{ status: string; data: ViewProps }> {
|
||||
return {
|
||||
data: { status: 'success', data: view },
|
||||
} as AxiosResponse<{ status: string; data: ViewProps }>;
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
): GetSavedView200 {
|
||||
return { status: 'success', data: view };
|
||||
}
|
||||
|
||||
describe('resourceRoute', () => {
|
||||
@@ -190,18 +198,33 @@ describe('resolveOpenResource', () => {
|
||||
|
||||
describe('findSavedViewInLists', () => {
|
||||
beforeEach(() => {
|
||||
mockedGetAllViews.mockReset();
|
||||
mockedListSavedViews.mockReset();
|
||||
});
|
||||
|
||||
it('loads only the hinted source when entity is provided', async () => {
|
||||
const tracesView = makeView('view-traces', DataSource.TRACES);
|
||||
mockedGetAllViews.mockResolvedValueOnce(mockViewsResponse([tracesView]));
|
||||
const tracesView = makeView('view-traces', SavedviewtypesSourceDTO.traces);
|
||||
mockedListSavedViews.mockResolvedValueOnce(mockViewsResponse([tracesView]));
|
||||
|
||||
const result = await findSavedViewInLists('view-traces', DataSource.TRACES);
|
||||
|
||||
expect(result).toStrictEqual(tracesView);
|
||||
expect(mockedGetAllViews).toHaveBeenCalledTimes(1);
|
||||
expect(mockedGetAllViews).toHaveBeenCalledWith(DataSource.TRACES);
|
||||
expect(mockedListSavedViews).toHaveBeenCalledTimes(1);
|
||||
expect(mockedListSavedViews).toHaveBeenCalledWith({
|
||||
source: SavedviewtypesSourceDTO.traces,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a null list as empty and probes the next source', async () => {
|
||||
const metricsView = makeView('view-metrics', SavedviewtypesSourceDTO.metrics);
|
||||
mockedListSavedViews
|
||||
.mockResolvedValueOnce({ status: 'success', data: null })
|
||||
.mockResolvedValueOnce(mockViewsResponse([]))
|
||||
.mockResolvedValueOnce(mockViewsResponse([metricsView]));
|
||||
|
||||
const result = await findSavedViewInLists('view-metrics');
|
||||
|
||||
expect(result).toStrictEqual(metricsView);
|
||||
expect(mockedListSavedViews).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -227,52 +250,75 @@ 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);
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.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);
|
||||
const params = new URLSearchParams(pushedUrl.split('?')[1]);
|
||||
expect(params.get(QueryParams.viewKey)).toBe('"view-logs"');
|
||||
expect(params.get(QueryParams.viewName)).toBe('"View view-logs"');
|
||||
expect(params.get(QueryParams.panelTypes)).toBe('"list"');
|
||||
});
|
||||
|
||||
it('throws when the view has no source', () => {
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
|
||||
delete view.source;
|
||||
|
||||
expect(() =>
|
||||
openSavedView(view, { push: jest.fn() } as unknown as History),
|
||||
).toThrow('Unsupported saved view source');
|
||||
});
|
||||
|
||||
it('throws when the view has no queries', () => {
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
|
||||
view.spec.queries = [];
|
||||
|
||||
expect(() =>
|
||||
openSavedView(view, { push: jest.fn() } as unknown as History),
|
||||
).toThrow('Saved view is missing query data');
|
||||
});
|
||||
});
|
||||
|
||||
describe('openSavedViewByKey', () => {
|
||||
beforeEach(() => {
|
||||
mockedGetAllViews.mockReset();
|
||||
mockedGetViewById.mockReset();
|
||||
mockedListSavedViews.mockReset();
|
||||
mockedGetSavedView.mockReset();
|
||||
});
|
||||
|
||||
it('prefers the direct view lookup endpoint', async () => {
|
||||
const view = makeView('view-logs', DataSource.LOGS);
|
||||
mockedGetViewById.mockResolvedValueOnce(mockViewByIdResponse(view));
|
||||
const view = makeView('view-logs', SavedviewtypesSourceDTO.logs);
|
||||
mockedGetSavedView.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(mockedGetSavedView).toHaveBeenCalledWith({ id: 'view-logs' });
|
||||
expect(mockedListSavedViews).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 view = makeView('view-traces', SavedviewtypesSourceDTO.traces);
|
||||
mockedGetSavedView.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedListSavedViews.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(mockedListSavedViews).toHaveBeenCalledWith({
|
||||
source: SavedviewtypesSourceDTO.traces,
|
||||
});
|
||||
expect(push).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the saved view does not exist', async () => {
|
||||
mockedGetViewById.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedGetAllViews.mockResolvedValue(mockViewsResponse([]));
|
||||
mockedGetSavedView.mockRejectedValueOnce(new Error('not found'));
|
||||
mockedListSavedViews.mockResolvedValue(mockViewsResponse([]));
|
||||
|
||||
await expect(
|
||||
openSavedViewByKey('missing', DataSource.LOGS, {
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { getAllViews } from 'api/saveView/getAllViews';
|
||||
import { getViewById } from 'api/saveView/getViewById';
|
||||
import {
|
||||
getSavedView,
|
||||
listSavedViews,
|
||||
} from 'api/generated/services/saved-view';
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import {
|
||||
findSavedView,
|
||||
getSavedViewQuery,
|
||||
SavedViewSourcePage,
|
||||
toSavedViewSource,
|
||||
} from 'container/SavedViews/utils';
|
||||
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';
|
||||
type SavedViewSourceHint = SavedViewSourcePage;
|
||||
|
||||
const DEFAULT_PROBE_SOURCES: SavedViewSourceHint[] = [
|
||||
DataSource.LOGS,
|
||||
@@ -20,13 +27,15 @@ const DEFAULT_PROBE_SOURCES: SavedViewSourceHint[] = [
|
||||
export async function findSavedViewInLists(
|
||||
viewKey: string,
|
||||
sourceHint?: SavedViewSourceHint | null,
|
||||
): Promise<ViewProps | null> {
|
||||
): Promise<SavedviewtypesSavedViewDTO | 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);
|
||||
const response = await listSavedViews({
|
||||
source: toSavedViewSource(source),
|
||||
});
|
||||
const match = findSavedView(response.data, viewKey);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
@@ -41,11 +50,11 @@ export async function findSavedViewInLists(
|
||||
async function loadSavedView(
|
||||
viewKey: string,
|
||||
sourceHint?: SavedViewSourceHint | null,
|
||||
): Promise<ViewProps> {
|
||||
): Promise<SavedviewtypesSavedViewDTO> {
|
||||
try {
|
||||
const response = await getViewById(viewKey);
|
||||
if (response.data?.data) {
|
||||
return response.data.data;
|
||||
const response = await getSavedView({ id: viewKey });
|
||||
if (response.data) {
|
||||
return response.data;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to list probing when the direct lookup fails.
|
||||
@@ -85,20 +94,23 @@ export function buildExplorerNavigationUrl(
|
||||
return `${route}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function openSavedView(view: ViewProps, history: History): void {
|
||||
const route = explorerRouteForSourcePage(view.sourcePage);
|
||||
export function openSavedView(
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
history: History,
|
||||
): void {
|
||||
const route = view.source ? explorerRouteForSourcePage(view.source) : null;
|
||||
if (!route) {
|
||||
throw new Error('Unsupported saved view source');
|
||||
}
|
||||
|
||||
if (!view.compositeQuery) {
|
||||
if (!view.spec.queries?.length) {
|
||||
throw new Error('Saved view is missing query data');
|
||||
}
|
||||
|
||||
const query = mapQueryDataFromApi(view.compositeQuery);
|
||||
const query = getSavedViewQuery(view);
|
||||
const url = buildExplorerNavigationUrl(route, query, {
|
||||
[QueryParams.panelTypes]: view.compositeQuery.panelType as PANEL_TYPES,
|
||||
[QueryParams.viewName]: view.name,
|
||||
[QueryParams.panelTypes]: view.spec.panelType as unknown as PANEL_TYPES,
|
||||
[QueryParams.viewName]: view.spec.displayName,
|
||||
[QueryParams.viewKey]: view.id,
|
||||
});
|
||||
history.push(url);
|
||||
@@ -112,6 +124,3 @@ export async function openSavedViewByKey(
|
||||
const view = await loadSavedView(viewKey, sourceHint);
|
||||
openSavedView(view, history);
|
||||
}
|
||||
|
||||
/** @deprecated Use findSavedViewInLists — kept for tests. */
|
||||
export const findSavedView = findSavedViewInLists;
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
.api-monitoring-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
.api-monitoring-explorer {
|
||||
.api-quick-filters-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
.api-quick-filter-left-section {
|
||||
width: 0%;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.api-quick-filters-header {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
@@ -161,16 +153,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
.api-quick-filter-left-section {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-filtered-domains-message-container {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
@@ -20,20 +19,21 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className={cx('api-monitoring-page', 'filter-visible')}>
|
||||
<section className="api-quick-filter-left-section">
|
||||
<QuickFilters
|
||||
className="qf-api-monitoring"
|
||||
source={QuickFiltersSource.API_MONITORING}
|
||||
signal={SignalType.API_MONITORING}
|
||||
showFilterCollapse={false}
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</section>
|
||||
<QuickFiltersLayout
|
||||
className="api-monitoring-explorer"
|
||||
showFilters
|
||||
quickFilterProps={{
|
||||
className: 'qf-api-monitoring',
|
||||
source: QuickFiltersSource.API_MONITORING,
|
||||
signal: SignalType.API_MONITORING,
|
||||
showFilterCollapse: false,
|
||||
showQueryName: false,
|
||||
handleFilterVisibilityChange: (): void => {},
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
<DomainList />
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { getViewDetailsUsingViewKey } from 'components/ExplorerCard/utils';
|
||||
import { useListSavedViews } from 'api/generated/services/saved-view';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { ViewProps } from 'types/api/saveViews/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import floppyDiscUrl from '@/assets/Icons/floppy-disc.svg';
|
||||
@@ -35,38 +36,40 @@ export default function SavedViews({
|
||||
}): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const [selectedEntity, setSelectedEntity] = useState<string>('logs');
|
||||
const [selectedEntityViews, setSelectedEntityViews] = useState<any[]>([]);
|
||||
const [selectedEntityViews, setSelectedEntityViews] = useState<
|
||||
SavedviewtypesSavedViewDTO[]
|
||||
>([]);
|
||||
|
||||
const {
|
||||
data: logsViewsData,
|
||||
isLoading: logsViewsLoading,
|
||||
isError: logsViewsError,
|
||||
} = useGetAllViews(DataSource.LOGS);
|
||||
} = useListSavedViews({ source: SavedviewtypesSourceDTO.logs });
|
||||
|
||||
const {
|
||||
data: tracesViewsData,
|
||||
isLoading: tracesViewsLoading,
|
||||
isError: tracesViewsError,
|
||||
} = useGetAllViews(DataSource.TRACES);
|
||||
} = useListSavedViews({ source: SavedviewtypesSourceDTO.traces });
|
||||
|
||||
const {
|
||||
data: metricsViewsData,
|
||||
isLoading: metricsViewsLoading,
|
||||
isError: metricsViewsError,
|
||||
} = useGetAllViews(DataSource.METRICS);
|
||||
} = useListSavedViews({ source: SavedviewtypesSourceDTO.metrics });
|
||||
|
||||
const logsViews = useMemo(
|
||||
() => [...(logsViewsData?.data.data || [])],
|
||||
() => [...(logsViewsData?.data || [])],
|
||||
[logsViewsData],
|
||||
);
|
||||
|
||||
const tracesViews = useMemo(
|
||||
() => [...(tracesViewsData?.data.data || [])],
|
||||
() => [...(tracesViewsData?.data || [])],
|
||||
[tracesViewsData],
|
||||
);
|
||||
|
||||
const metricsViews = useMemo(
|
||||
() => [...(metricsViewsData?.data.data || [])],
|
||||
() => [...(metricsViewsData?.data || [])],
|
||||
[metricsViewsData],
|
||||
);
|
||||
|
||||
@@ -88,39 +91,22 @@ export default function SavedViews({
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
|
||||
const handleRedirectQuery = (view: ViewProps): void => {
|
||||
const handleRedirectQuery = (view: SavedviewtypesSavedViewDTO): void => {
|
||||
logEvent('Homepage: Saved view clicked', {
|
||||
viewId: view.id,
|
||||
viewName: view.name,
|
||||
viewName: view.spec.displayName,
|
||||
entity: selectedEntity,
|
||||
});
|
||||
|
||||
let currentViews: ViewProps[] = [];
|
||||
if (selectedEntity === 'logs') {
|
||||
currentViews = logsViews;
|
||||
} else if (selectedEntity === 'traces') {
|
||||
currentViews = tracesViews;
|
||||
} else if (selectedEntity === 'metrics') {
|
||||
currentViews = metricsViews;
|
||||
}
|
||||
|
||||
const currentViewDetails = getViewDetailsUsingViewKey(view.id, currentViews);
|
||||
if (!currentViewDetails) {
|
||||
return;
|
||||
}
|
||||
const { query, name, id, panelType: currentPanelType } = currentViewDetails;
|
||||
|
||||
if (selectedEntity) {
|
||||
handleExplorerTabChange(
|
||||
currentPanelType,
|
||||
{
|
||||
query,
|
||||
viewName: name,
|
||||
viewKey: id,
|
||||
},
|
||||
SOURCEPAGE_VS_ROUTES[selectedEntity],
|
||||
);
|
||||
}
|
||||
handleExplorerTabChange(
|
||||
view.spec.panelType,
|
||||
{
|
||||
query: getSavedViewQuery(view),
|
||||
viewName: view.spec.displayName,
|
||||
viewKey: view.id,
|
||||
},
|
||||
SOURCEPAGE_VS_ROUTES[selectedEntity],
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -239,24 +225,10 @@ export default function SavedViews({
|
||||
/>
|
||||
|
||||
<div className="saved-view-item-name home-data-item-name">
|
||||
{view.name}
|
||||
{view.spec.displayName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="saved-view-item-description home-data-item-tag">
|
||||
{view.tags?.map((tag: string) => {
|
||||
if (tag === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge color="sienna" key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@@ -307,7 +279,7 @@ export default function SavedViews({
|
||||
logEvent('Homepage: Saved views switched', {
|
||||
tab,
|
||||
});
|
||||
let currentViews: ViewProps[] = [];
|
||||
let currentViews: SavedviewtypesSavedViewDTO[] = [];
|
||||
if (tab === 'logs') {
|
||||
currentViews = logsViews;
|
||||
} else if (tab === 'traces') {
|
||||
|
||||
@@ -65,8 +65,6 @@
|
||||
}
|
||||
|
||||
.trace-explorer-page {
|
||||
display: flex;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
@@ -75,32 +73,8 @@
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
|
||||
.filter {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
border-right: 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
width: 258px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer {
|
||||
width: 100%;
|
||||
background: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
.trace-explorer.filters-expanded {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
@@ -188,26 +186,21 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div
|
||||
<QuickFiltersLayout
|
||||
className="trace-explorer-page"
|
||||
data-testid="llm-observability-explorer"
|
||||
testId="llm-observability-explorer"
|
||||
showFilters={isOpen}
|
||||
quickFilterProps={{
|
||||
className: 'qf-traces-explorer',
|
||||
source: QuickFiltersSource.AI_OBSERVABILITY,
|
||||
signal: SignalType.AI_OBSERVABILITY,
|
||||
useFieldApis: quickFiltersFieldApis,
|
||||
handleFilterVisibilityChange: (): void => {
|
||||
setOpen(!isOpen);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Card className="filter" hidden={!isOpen}>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.AI_OBSERVABILITY}
|
||||
signal={SignalType.AI_OBSERVABILITY}
|
||||
useFieldApis={quickFiltersFieldApis}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<div
|
||||
className={cx('trace-explorer', {
|
||||
'filters-expanded': isOpen,
|
||||
})}
|
||||
>
|
||||
<div className="trace-explorer">
|
||||
<div className="trace-explorer-header">
|
||||
<Toolbar
|
||||
showAutoRefresh
|
||||
@@ -291,7 +284,7 @@ function Explorer(): JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
.meter-explorer-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.meter-explorer-quick-filters-section {
|
||||
width: 280px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.meter-explorer-content-section {
|
||||
width: 100%;
|
||||
// Clearance for the fixed ExplorerOptions bar.
|
||||
padding-bottom: 80px;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
@@ -83,14 +72,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.quick-filters-open {
|
||||
.meter-explorer-content-section {
|
||||
width: calc(100% - 280px);
|
||||
}
|
||||
}
|
||||
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.dashboards-and-alerts-popover-container {
|
||||
|
||||
@@ -3,9 +3,8 @@ import { useQueryClient } from 'react-query';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -121,29 +120,21 @@ function Explorer(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div
|
||||
className={cx('meter-explorer-container', {
|
||||
'quick-filters-open': showQuickFilters,
|
||||
})}
|
||||
<QuickFiltersLayout
|
||||
className="meter-explorer-container"
|
||||
showFilters={showQuickFilters}
|
||||
quickFilterProps={{
|
||||
className: 'qf-meter-explorer',
|
||||
source: QuickFiltersSource.METER_EXPLORER,
|
||||
signal: SignalType.METER_EXPLORER,
|
||||
showFilterCollapse: true,
|
||||
showQueryName: false,
|
||||
handleFilterVisibilityChange: (): void => {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
},
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cx('meter-explorer-quick-filters-section', {
|
||||
hidden: !showQuickFilters,
|
||||
})}
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-meter-explorer"
|
||||
source={QuickFiltersSource.METER_EXPLORER}
|
||||
signal={SignalType.METER_EXPLORER}
|
||||
showFilterCollapse
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
}}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="meter-explorer-content-section">
|
||||
<div className="meter-explorer-explore-content">
|
||||
<div className="explore-header">
|
||||
@@ -196,7 +187,7 @@ function Explorer(): JSX.Element {
|
||||
splitedQueries={splitedQueries}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</QuickFiltersLayout>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,9 +60,6 @@
|
||||
.metrics-table-container {
|
||||
padding-bottom: 48px;
|
||||
.ant-table {
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
padding: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
126
frontend/src/container/SavedViews/__tests__/utils.test.ts
Normal file
126
frontend/src/container/SavedViews/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
SavedviewtypesPanelTypeDTO,
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { findSavedView, getSavedViewQuery, toSavedViewSource } from '../utils';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: (): string => 'test-id',
|
||||
}));
|
||||
|
||||
function makeView(): SavedviewtypesSavedViewDTO {
|
||||
return {
|
||||
id: 'view-1',
|
||||
name: 'errors-by-service-abc123',
|
||||
source: SavedviewtypesSourceDTO.traces,
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
|
||||
createdBy: 'a@b.c',
|
||||
updatedBy: 'a@b.c',
|
||||
spec: {
|
||||
displayName: 'Errors by service',
|
||||
panelType: SavedviewtypesPanelTypeDTO.list,
|
||||
requestType: 'raw',
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'traces',
|
||||
stepInterval: 60,
|
||||
filter: { expression: 'has_error = true' },
|
||||
// v2 reads back fully defaulted envelopes; nulls must not break the mapper
|
||||
groupBy: null,
|
||||
order: null,
|
||||
selectFields: null,
|
||||
functions: null,
|
||||
legend: '',
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
selectedFields: [{ name: 'service.name' }],
|
||||
display: { color: 'red' },
|
||||
},
|
||||
} as SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
describe('getSavedViewQuery', () => {
|
||||
it('maps the v2 spec through the v5 branch of mapQueryDataFromApi', () => {
|
||||
const query = getSavedViewQuery(makeView());
|
||||
|
||||
expect(query.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(query.promql).toStrictEqual([]);
|
||||
expect(query.clickhouse_sql).toStrictEqual([]);
|
||||
expect(query.builder.queryData).toHaveLength(1);
|
||||
|
||||
const [queryData] = query.builder.queryData;
|
||||
expect(queryData.queryName).toBe('A');
|
||||
expect(queryData.dataSource).toBe(DataSource.TRACES);
|
||||
expect(queryData.filter).toStrictEqual({ expression: 'has_error = true' });
|
||||
expect(queryData.groupBy).toStrictEqual([]);
|
||||
expect(queryData.orderBy).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('keeps formulas alongside builder queries', () => {
|
||||
const view = makeView();
|
||||
view.spec.queries.push({
|
||||
type: 'builder_formula',
|
||||
spec: { name: 'F1', expression: 'A / 2' },
|
||||
} as SavedviewtypesSavedViewDTO['spec']['queries'][number]);
|
||||
|
||||
const query = getSavedViewQuery(view);
|
||||
|
||||
expect(query.builder.queryData).toHaveLength(1);
|
||||
expect(query.builder.queryFormulas).toHaveLength(1);
|
||||
expect(query.builder.queryFormulas[0].queryName).toBe('F1');
|
||||
});
|
||||
|
||||
it('does not read the panel type into the query', () => {
|
||||
const view = makeView();
|
||||
view.spec.panelType = SavedviewtypesPanelTypeDTO.graph;
|
||||
|
||||
const query = getSavedViewQuery(view);
|
||||
|
||||
// panelType travels separately (url param), the Query itself has no such field
|
||||
expect(query).not.toHaveProperty('panelType', PANEL_TYPES.TIME_SERIES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSavedViewSource', () => {
|
||||
it('maps every explorer source page to the v2 source', () => {
|
||||
expect(toSavedViewSource(DataSource.LOGS)).toBe(SavedviewtypesSourceDTO.logs);
|
||||
expect(toSavedViewSource(DataSource.TRACES)).toBe(
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
expect(toSavedViewSource(DataSource.METRICS)).toBe(
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
);
|
||||
expect(toSavedViewSource('meter')).toBe(SavedviewtypesSourceDTO.meter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSavedView', () => {
|
||||
const views = [
|
||||
{ ...makeView(), id: 'a' },
|
||||
{ ...makeView(), id: 'b' },
|
||||
];
|
||||
|
||||
it('returns the view with the matching id', () => {
|
||||
expect(findSavedView(views, 'b')?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('returns undefined when the id is not in the list', () => {
|
||||
expect(findSavedView(views, 'c')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a null or not yet loaded list', () => {
|
||||
expect(findSavedView(null, 'a')).toBeUndefined();
|
||||
expect(findSavedView(undefined, 'a')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
49
frontend/src/container/SavedViews/utils.ts
Normal file
49
frontend/src/container/SavedViews/utils.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryEnvelope } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export type SavedViewSourcePage = DataSource | 'meter';
|
||||
|
||||
// Explorers and the preferences module are keyed by DataSource (the signal),
|
||||
// the api keys views by source page. Same values today, so this is the one
|
||||
// place they meet. AI observability views will come with their own source and
|
||||
// DataSource cannot tell them apart from traces, so preferences should move to
|
||||
// source page at that point and this map goes with it.
|
||||
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
|
||||
{
|
||||
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
|
||||
[DataSource.TRACES]: SavedviewtypesSourceDTO.traces,
|
||||
[DataSource.METRICS]: SavedviewtypesSourceDTO.metrics,
|
||||
meter: SavedviewtypesSourceDTO.meter,
|
||||
};
|
||||
|
||||
export function toSavedViewSource(
|
||||
sourcePage: SavedViewSourcePage,
|
||||
): SavedviewtypesSourceDTO {
|
||||
return SAVED_VIEW_SOURCE[sourcePage];
|
||||
}
|
||||
|
||||
// Explorers only save builder queries; v2 carries no queryType, so it is fixed here.
|
||||
export function getSavedViewQuery(view: SavedviewtypesSavedViewDTO): Query {
|
||||
const { queries, panelType } = view.spec;
|
||||
return mapQueryDataFromApi({
|
||||
queries: queries as QueryEnvelope[],
|
||||
panelType: panelType as unknown as PANEL_TYPES,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
unit: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function findSavedView(
|
||||
views: SavedviewtypesSavedViewDTO[] | null | undefined,
|
||||
id: string,
|
||||
): SavedviewtypesSavedViewDTO | undefined {
|
||||
return views?.find((view) => view.id === id);
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import { useMutation, UseMutationResult } from 'react-query';
|
||||
import { useMutation, UseMutationResult, useQueryClient } from 'react-query';
|
||||
import { invalidateListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { deleteView } from 'api/saveView/deleteView';
|
||||
import { DeleteViewPayloadProps } from 'types/api/saveViews/types';
|
||||
|
||||
export const useDeleteView = (
|
||||
uuid: string,
|
||||
): UseMutationResult<DeleteViewPayloadProps, Error, string> =>
|
||||
useMutation({
|
||||
): UseMutationResult<DeleteViewPayloadProps, Error, string> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: [uuid],
|
||||
mutationFn: () => deleteView(uuid),
|
||||
// v1 and v2 share storage; consumers already on v2 must see this write.
|
||||
// Temporary till the v1 client is deleted with the explorer bar.
|
||||
onSuccess: () => invalidateListSavedViews(queryClient),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation, UseMutationResult } from 'react-query';
|
||||
import { useMutation, UseMutationResult, useQueryClient } from 'react-query';
|
||||
import { invalidateListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { saveView } from 'api/saveView/saveView';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { SaveViewPayloadProps, SaveViewProps } from 'types/api/saveViews/types';
|
||||
@@ -13,8 +14,14 @@ export const useSaveView = ({
|
||||
Error,
|
||||
SaveViewProps,
|
||||
SaveViewPayloadProps
|
||||
> =>
|
||||
useMutation({
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: [viewName, sourcePage, compositeQuery, extraData],
|
||||
mutationFn: saveView,
|
||||
// v1 and v2 share storage; consumers already on v2 must see this write.
|
||||
// Temporary till the v1 client is deleted with the explorer bar.
|
||||
onSuccess: () => invalidateListSavedViews(queryClient),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation, UseMutationResult } from 'react-query';
|
||||
import { useMutation, UseMutationResult, useQueryClient } from 'react-query';
|
||||
import { invalidateListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { updateView } from 'api/saveView/updateView';
|
||||
import {
|
||||
UpdateViewPayloadProps,
|
||||
@@ -16,8 +17,10 @@ export const useUpdateView = ({
|
||||
Error,
|
||||
UpdateViewProps,
|
||||
UpdateViewPayloadProps
|
||||
> =>
|
||||
useMutation({
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: [viewName, sourcePage, compositeQuery, extraData],
|
||||
mutationFn: () =>
|
||||
updateView({
|
||||
@@ -27,4 +30,8 @@ export const useUpdateView = ({
|
||||
sourcePage,
|
||||
viewKey,
|
||||
}),
|
||||
// v1 and v2 share storage; consumers already on v2 must see this write.
|
||||
// Temporary till the v1 client is deleted with the explorer bar.
|
||||
onSuccess: () => invalidateListSavedViews(queryClient),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ describe('Get Series Data', () => {
|
||||
expect(seriesData).toHaveLength(5);
|
||||
expect(seriesData[1].label).toBe('firstLegend');
|
||||
expect(seriesData[1].show).toBe(true);
|
||||
expect(seriesData[1].fill).toBe('#FF6F91');
|
||||
expect(seriesData[1].fill).toBe('#83C2EB');
|
||||
expect(seriesData[1].width).toBe(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -57,3 +57,12 @@
|
||||
color: var(--l3-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* The bottom legend's box is only the rows reserved for it. */
|
||||
.container:not(.isRight) .emptyState {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { LegendAction, LegendPosition, LegendProps } from '../types';
|
||||
import { LEGEND_ITEM_EXTRA_WIDTH, MAX_LEGEND_WIDTH } from './constants';
|
||||
import LegendRow from './LegendRow';
|
||||
import LegendToolbar from './LegendToolbar';
|
||||
import { filterLegendItems, getShownSeriesState } from './utils';
|
||||
import { getVisibleSeriesState } from './utils';
|
||||
|
||||
import styles from './Legend.module.scss';
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function Legend({
|
||||
items,
|
||||
position,
|
||||
averageLegendWidth = MAX_LEGEND_WIDTH,
|
||||
showSearch = false,
|
||||
focusedSeriesIndex,
|
||||
onAction,
|
||||
showCopy = true,
|
||||
@@ -30,27 +31,22 @@ export default function Legend({
|
||||
const itemWidth = averageLegendWidth + LEGEND_ITEM_EXTRA_WIDTH;
|
||||
const isRightPosition = position === LegendPosition.RIGHT;
|
||||
|
||||
const { visibleCount, soleShownSeriesIndex } = useMemo(
|
||||
() => getShownSeriesState(items),
|
||||
[items],
|
||||
);
|
||||
// The layout decides: it reserves the height.
|
||||
const showToolbar = showSearch && items.length > 0;
|
||||
|
||||
// A bottom legend gets two rows; spending one on chrome costs more chart than
|
||||
// the readout is worth.
|
||||
const showToolbar = isRightPosition && items.length > 0;
|
||||
const showFilter = showToolbar;
|
||||
const effectiveQuery = showToolbar ? filterQuery : '';
|
||||
|
||||
const effectiveQuery = showFilter ? filterQuery : '';
|
||||
|
||||
const visibleLegendItems = useMemo(
|
||||
() => filterLegendItems(items, effectiveQuery),
|
||||
const {
|
||||
listedItems,
|
||||
visibleCount,
|
||||
onlyVisibleSeriesIndex,
|
||||
areAllSeriesVisible,
|
||||
} = useMemo(
|
||||
() => getVisibleSeriesState(items, effectiveQuery),
|
||||
[items, effectiveQuery],
|
||||
);
|
||||
|
||||
const isEmptyState =
|
||||
!!effectiveQuery.trim() && visibleLegendItems.length === 0;
|
||||
|
||||
const isAllShown = visibleCount === items.length;
|
||||
const isEmptyState = !!effectiveQuery.trim() && listedItems.length === 0;
|
||||
|
||||
// A row that unmounts under the pointer never fires its own mouseleave.
|
||||
const handleMouseLeave = useCallback(
|
||||
@@ -63,14 +59,20 @@ export default function Legend({
|
||||
<LegendRow
|
||||
key={item.seriesIndex}
|
||||
item={item}
|
||||
isSoleShown={soleShownSeriesIndex === item.seriesIndex}
|
||||
isAllShown={isAllShown}
|
||||
isOneSeriesVisible={onlyVisibleSeriesIndex === item.seriesIndex}
|
||||
areAllSeriesVisible={areAllSeriesVisible}
|
||||
isFocused={focusedSeriesIndex === item.seriesIndex}
|
||||
showCopy={showCopy}
|
||||
onAction={onAction}
|
||||
/>
|
||||
),
|
||||
[soleShownSeriesIndex, isAllShown, focusedSeriesIndex, showCopy, onAction],
|
||||
[
|
||||
onlyVisibleSeriesIndex,
|
||||
areAllSeriesVisible,
|
||||
focusedSeriesIndex,
|
||||
showCopy,
|
||||
onAction,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -87,7 +89,7 @@ export default function Legend({
|
||||
<LegendToolbar
|
||||
visibleCount={visibleCount}
|
||||
totalCount={items.length}
|
||||
showFilter={showFilter}
|
||||
position={position}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
/>
|
||||
@@ -101,7 +103,7 @@ export default function Legend({
|
||||
className={styles.scroller}
|
||||
listClassName={styles.gridList}
|
||||
itemClassName={styles.gridItem}
|
||||
data={visibleLegendItems}
|
||||
data={listedItems}
|
||||
itemContent={(_, item): JSX.Element => renderLegendItem(item)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -13,9 +13,9 @@ import styles from './LegendRow.module.scss';
|
||||
export interface LegendRowProps {
|
||||
item: LegendItem;
|
||||
/** The only series currently shown, so hiding it is refused. */
|
||||
isSoleShown: boolean;
|
||||
isOneSeriesVisible: boolean;
|
||||
/** Nothing is hidden, so the row's action can only narrow the selection. */
|
||||
isAllShown: boolean;
|
||||
areAllSeriesVisible: boolean;
|
||||
isFocused: boolean;
|
||||
showCopy: boolean;
|
||||
onAction: OnLegendAction;
|
||||
@@ -29,15 +29,15 @@ export interface LegendRowProps {
|
||||
*/
|
||||
function LegendRow({
|
||||
item,
|
||||
isSoleShown,
|
||||
isAllShown,
|
||||
isOneSeriesVisible,
|
||||
areAllSeriesVisible,
|
||||
isFocused,
|
||||
showCopy,
|
||||
onAction,
|
||||
}: LegendRowProps): JSX.Element {
|
||||
const { seriesIndex, show } = item;
|
||||
const label = item.label ?? '';
|
||||
const isShowAllAction = show && !isAllShown;
|
||||
const isShowAllAction = show && !areAllSeriesVisible;
|
||||
const scopeActionLabel = isShowAllAction
|
||||
? 'Show all series'
|
||||
: 'Show only current series';
|
||||
@@ -47,15 +47,15 @@ function LegendRow({
|
||||
|
||||
/** Everything showing -> isolate; showing alone -> restore all. */
|
||||
const handleRowClick = useCallback((): void => {
|
||||
if (isSoleShown) {
|
||||
if (isOneSeriesVisible) {
|
||||
onAction({ type: LegendAction.SHOW_ALL });
|
||||
return;
|
||||
}
|
||||
onAction({
|
||||
type: isAllShown ? LegendAction.SHOW_ONLY : LegendAction.TOGGLE,
|
||||
type: areAllSeriesVisible ? LegendAction.SHOW_ONLY : LegendAction.TOGGLE,
|
||||
seriesIndex,
|
||||
});
|
||||
}, [isSoleShown, isAllShown, onAction, seriesIndex]);
|
||||
}, [isOneSeriesVisible, areAllSeriesVisible, onAction, seriesIndex]);
|
||||
|
||||
const handleMarkerClick = useCallback(
|
||||
(event: MouseEvent<HTMLButtonElement>): void => {
|
||||
@@ -126,7 +126,7 @@ function LegendRow({
|
||||
backgroundColor: show ? seriesColor : 'transparent',
|
||||
}}
|
||||
onClick={handleMarkerClick}
|
||||
disabled={isSoleShown}
|
||||
disabled={isOneSeriesVisible}
|
||||
aria-label={`${show ? 'Hide' : 'Show'} ${label}`}
|
||||
data-is-legend-marker={true}
|
||||
data-testid={`legend-marker-${seriesIndex}`}
|
||||
|
||||
@@ -33,3 +33,32 @@
|
||||
.searchIcon {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
/* Height + margin must match LEGEND_TOOLBAR_HEIGHT and LEGEND_TOOLBAR_GAP. */
|
||||
.inlineToolbar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
height: 24px;
|
||||
margin-bottom: var(--spacing-2);
|
||||
padding-right: var(--spacing-4);
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 0 0 auto;
|
||||
width: 240px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.statusInline {
|
||||
// Truncates rather than wrapping onto a row the legend has not reserved.
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.searchInputInline {
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { ChangeEvent, useCallback } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Input } from 'antd';
|
||||
import { Search } from '@signozhq/icons';
|
||||
|
||||
import { LegendPosition } from '../types';
|
||||
|
||||
import styles from './LegendToolbar.module.scss';
|
||||
|
||||
export interface LegendToolbarProps {
|
||||
visibleCount: number;
|
||||
totalCount: number;
|
||||
/** Search is intrinsic to the right-positioned legend. */
|
||||
showFilter: boolean;
|
||||
/** Layout only: the column stacks, the bottom row does not. */
|
||||
position: LegendPosition;
|
||||
filterQuery: string;
|
||||
onFilterQueryChange: (query: string) => void;
|
||||
}
|
||||
@@ -17,7 +20,7 @@ export interface LegendToolbarProps {
|
||||
export default function LegendToolbar({
|
||||
visibleCount,
|
||||
totalCount,
|
||||
showFilter,
|
||||
position,
|
||||
filterQuery,
|
||||
onFilterQueryChange,
|
||||
}: LegendToolbarProps): JSX.Element {
|
||||
@@ -27,30 +30,48 @@ export default function LegendToolbar({
|
||||
[onFilterQueryChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showFilter && (
|
||||
const searchProps = {
|
||||
allowClear: true,
|
||||
prefix: <Search size={12} className={styles.searchIcon} />,
|
||||
placeholder: 'Search...',
|
||||
value: filterQuery,
|
||||
onChange: handleFilterChange,
|
||||
className: styles.searchInput,
|
||||
'data-testid': 'legend-search-input',
|
||||
};
|
||||
|
||||
const status = (
|
||||
<span
|
||||
className={cx(styles.status, {
|
||||
[styles.statusInline]: position !== LegendPosition.RIGHT,
|
||||
})}
|
||||
aria-live="polite"
|
||||
data-testid="legend-status"
|
||||
>
|
||||
{`Showing ${visibleCount} of ${totalCount} series`}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (position === LegendPosition.RIGHT) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.searchContainer}>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<Search size={12} className={styles.searchIcon} />}
|
||||
placeholder="Search..."
|
||||
value={filterQuery}
|
||||
onChange={handleFilterChange}
|
||||
className={styles.searchInput}
|
||||
data-testid="legend-search-input"
|
||||
/>
|
||||
<Input {...searchProps} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.toolbar}>
|
||||
<span
|
||||
className={styles.status}
|
||||
aria-live="polite"
|
||||
data-testid="legend-status"
|
||||
>
|
||||
{`Showing ${visibleCount} of ${totalCount} series`}
|
||||
</span>
|
||||
<div className={styles.toolbar}>{status}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.inlineToolbar}>
|
||||
<div className={styles.search}>
|
||||
<Input
|
||||
{...searchProps}
|
||||
className={cx(styles.searchInput, styles.searchInputInline)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
{status}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function UPlotLegend({
|
||||
position = LegendPosition.BOTTOM,
|
||||
config,
|
||||
averageLegendWidth,
|
||||
showSearch,
|
||||
}: UPlotLegendProps): JSX.Element {
|
||||
const { legendItemsMap, focusedSeriesIndex } = useLegendsSync({ config });
|
||||
const onAction = useLegendActions();
|
||||
@@ -27,6 +28,7 @@ export default function UPlotLegend({
|
||||
items={items}
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
showSearch={showSearch}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onAction={onAction}
|
||||
/>
|
||||
|
||||
@@ -88,11 +88,15 @@ describe('UPlotLegend', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderLegend = (position?: LegendPosition): RenderResult =>
|
||||
const renderLegend = (
|
||||
position?: LegendPosition,
|
||||
showSearch = true,
|
||||
): RenderResult =>
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<UPlotLegend
|
||||
position={position}
|
||||
showSearch={showSearch}
|
||||
// config is consumed by the mocked useLegendsSync hook, not directly
|
||||
config={{} as any}
|
||||
/>
|
||||
@@ -100,14 +104,38 @@ describe('UPlotLegend', () => {
|
||||
);
|
||||
|
||||
describe('layout and position', () => {
|
||||
it('renders the search input on a RIGHT legend', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
it.each([LegendPosition.RIGHT, LegendPosition.BOTTOM])(
|
||||
'gives the legend a search box and a readout (%s)',
|
||||
(position) => {
|
||||
renderLegend(position);
|
||||
|
||||
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('legend-status')).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps a BOTTOM legend bare — its two rows all go to series', () => {
|
||||
renderLegend();
|
||||
it.each([LegendPosition.RIGHT, LegendPosition.BOTTOM])(
|
||||
'counts down the readout as the search narrows the list (%s)',
|
||||
async (position) => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(position);
|
||||
|
||||
// B is hidden.
|
||||
expect(screen.getByTestId('legend-status')).toHaveTextContent(
|
||||
'Showing 2 of 3 series',
|
||||
);
|
||||
|
||||
await user.type(screen.getByTestId('legend-search-input'), 'a');
|
||||
|
||||
// Only A matches, counted against all three.
|
||||
expect(screen.getByTestId('legend-status')).toHaveTextContent(
|
||||
'Showing 1 of 3 series',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps a BOTTOM legend bare while every series is on screen', () => {
|
||||
renderLegend(LegendPosition.BOTTOM, false);
|
||||
|
||||
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('legend-status')).not.toBeInTheDocument();
|
||||
@@ -116,6 +144,16 @@ describe('UPlotLegend', () => {
|
||||
expect(screen.getByTestId('legend-scope-0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters a BOTTOM legend from its search box', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.BOTTOM);
|
||||
|
||||
await user.type(screen.getByTestId('legend-search-input'), 'b');
|
||||
|
||||
expect(screen.getByText('B')).toBeInTheDocument();
|
||||
expect(screen.queryByText('A')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the marker with the series colour, filled only when shown', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { filterLegendItems, getShownSeriesState } from '../utils';
|
||||
import { filterLegendItems, getVisibleSeriesState } from '../utils';
|
||||
|
||||
const items = (shown: boolean[]): LegendItem[] =>
|
||||
shown.map((show, index) => ({
|
||||
@@ -10,26 +10,49 @@ const items = (shown: boolean[]): LegendItem[] =>
|
||||
show,
|
||||
}));
|
||||
|
||||
describe('getShownSeriesState', () => {
|
||||
describe('getVisibleSeriesState', () => {
|
||||
it('counts the shown series', () => {
|
||||
expect(getShownSeriesState(items([true, false, true]))).toStrictEqual({
|
||||
visibleCount: 2,
|
||||
soleShownSeriesIndex: null,
|
||||
});
|
||||
const state = getVisibleSeriesState(items([true, false, true]), '');
|
||||
|
||||
expect(state.visibleCount).toBe(2);
|
||||
expect(state.onlyVisibleSeriesIndex).toBeNull();
|
||||
expect(state.areAllSeriesVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('names the series when exactly one is shown', () => {
|
||||
expect(getShownSeriesState(items([false, true, false]))).toStrictEqual({
|
||||
visibleCount: 1,
|
||||
soleShownSeriesIndex: 2,
|
||||
});
|
||||
const state = getVisibleSeriesState(items([false, true, false]), '');
|
||||
|
||||
expect(state.visibleCount).toBe(1);
|
||||
expect(state.onlyVisibleSeriesIndex).toBe(2);
|
||||
});
|
||||
|
||||
it('reports nothing shown', () => {
|
||||
expect(getShownSeriesState(items([false, false]))).toStrictEqual({
|
||||
visibleCount: 0,
|
||||
soleShownSeriesIndex: null,
|
||||
});
|
||||
const state = getVisibleSeriesState(items([false, false]), '');
|
||||
|
||||
expect(state.visibleCount).toBe(0);
|
||||
expect(state.onlyVisibleSeriesIndex).toBeNull();
|
||||
});
|
||||
|
||||
it('reports every series shown', () => {
|
||||
expect(
|
||||
getVisibleSeriesState(items([true, true]), '').areAllSeriesVisible,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('counts only the series the search listed', () => {
|
||||
const state = getVisibleSeriesState(items([true, true, false]), 'series-1');
|
||||
|
||||
expect(state.listedItems.map((item) => item.label)).toStrictEqual([
|
||||
'series-1',
|
||||
]);
|
||||
expect(state.visibleCount).toBe(1);
|
||||
});
|
||||
|
||||
it('reads isolation off every series, not the listed ones', () => {
|
||||
const state = getVisibleSeriesState(items([false, true, false]), 'series-2');
|
||||
|
||||
expect(state.listedItems).toHaveLength(1);
|
||||
expect(state.onlyVisibleSeriesIndex).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,5 +21,9 @@ export const LEGEND_ROW_HEIGHT = 28;
|
||||
export const LEGEND_ROW_GAP = 2;
|
||||
export const LEGEND_MAX_BOTTOM_ROWS = 2;
|
||||
|
||||
/** Must match `.inlineToolbar`'s height and margin-bottom, or it eats a row. */
|
||||
export const LEGEND_TOOLBAR_HEIGHT = 24;
|
||||
export const LEGEND_TOOLBAR_GAP = 4;
|
||||
|
||||
/** Hover delay before a row's full-name tooltip opens. */
|
||||
export const LEGEND_TOOLTIP_DELAY_MS = 500;
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
|
||||
export interface ShownSeriesState {
|
||||
export interface LegendViewState {
|
||||
listedItems: LegendItem[];
|
||||
/** Listed items that are toggled on, against every series in the readout. */
|
||||
visibleCount: number;
|
||||
/** The series index when exactly one series is shown, else null. */
|
||||
soleShownSeriesIndex: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Driven by what is actually shown, never a remembered isolation: hiding series
|
||||
* one at a time down to a single one is the same state as "Only".
|
||||
*/
|
||||
export function getShownSeriesState(items: LegendItem[]): ShownSeriesState {
|
||||
const shown = items.filter((item) => item.show);
|
||||
|
||||
return {
|
||||
visibleCount: shown.length,
|
||||
soleShownSeriesIndex: shown.length === 1 ? shown[0].seriesIndex : null,
|
||||
};
|
||||
/** The series index when exactly one series is toggled on, else null. */
|
||||
onlyVisibleSeriesIndex: number | null;
|
||||
areAllSeriesVisible: boolean;
|
||||
}
|
||||
|
||||
export function filterLegendItems(
|
||||
@@ -32,3 +22,23 @@ export function filterLegendItems(
|
||||
item.label?.toLowerCase().includes(normalisedQuery),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Isolation is driven by what is actually shown, never a remembered one: hiding
|
||||
* series one at a time down to a single one is the same state as "Only". It is
|
||||
* read off the whole series set, not off what the search left listed.
|
||||
*/
|
||||
export function getVisibleSeriesState(
|
||||
items: LegendItem[],
|
||||
query: string,
|
||||
): LegendViewState {
|
||||
const visible = items.filter((item) => item.show);
|
||||
const listedItems = filterLegendItems(items, query);
|
||||
|
||||
return {
|
||||
listedItems,
|
||||
visibleCount: listedItems.filter((item) => item.show).length,
|
||||
onlyVisibleSeriesIndex: visible.length === 1 ? visible[0].seriesIndex : null,
|
||||
areAllSeriesVisible: visible.length === items.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,9 +73,9 @@ function createTooltipContent(
|
||||
};
|
||||
}
|
||||
|
||||
function createUPlotInstance(cursorIdx: number | null): uPlot {
|
||||
function createUPlotInstance(cursorIdx: number | null, timestamp = 1): uPlot {
|
||||
return {
|
||||
data: [[1], []],
|
||||
data: [[timestamp], []],
|
||||
cursor: { idx: cursorIdx },
|
||||
// The rest of the uPlot fields are not used by Tooltip
|
||||
} as unknown as uPlot;
|
||||
@@ -122,6 +122,19 @@ describe('Tooltip', () => {
|
||||
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops the date from the header title for a point on the current day', () => {
|
||||
const todayTimestamp = dayjs().tz('UTC').startOf('hour').unix();
|
||||
const uPlotInstance = createUPlotInstance(0, todayTimestamp);
|
||||
|
||||
renderTooltip({ uPlotInstance });
|
||||
|
||||
const expectedTitle = dayjs(todayTimestamp * 1000)
|
||||
.tz('UTC')
|
||||
.format(DATE_TIME_FORMATS.TIME_SECONDS);
|
||||
|
||||
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render header when showTooltipHeader is false', () => {
|
||||
const uPlotInstance = createUPlotInstance(0);
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import cx from 'classnames';
|
||||
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { Pin } from '@signozhq/icons';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import type uPlot from 'uplot';
|
||||
import { formatTimestampOmittingTodaysDate } from 'utils/timeUtils';
|
||||
|
||||
import { TooltipContentItem } from '../../../types';
|
||||
import TooltipItem from '../TooltipItem/TooltipItem';
|
||||
@@ -19,6 +18,7 @@ interface TooltipHeaderProps {
|
||||
isPinned: boolean;
|
||||
activeItem: TooltipContentItem | null;
|
||||
headerRowClassName?: string;
|
||||
/** Overrides the default, which drops the date part for points on the current day. */
|
||||
dateFormat?: string;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function TooltipHeader({
|
||||
isPinned,
|
||||
activeItem,
|
||||
headerRowClassName,
|
||||
dateFormat = DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS,
|
||||
dateFormat,
|
||||
}: TooltipHeaderProps): JSX.Element {
|
||||
const { timezone: userTimezone } = useTimezone();
|
||||
const resolvedTimezone = timezone?.value ?? userTimezone.value;
|
||||
@@ -46,9 +46,11 @@ export default function TooltipHeader({
|
||||
if (timestamp == null) {
|
||||
return null;
|
||||
}
|
||||
return dayjs(timestamp * 1000)
|
||||
.tz(resolvedTimezone)
|
||||
.format(dateFormat);
|
||||
return formatTimestampOmittingTodaysDate(
|
||||
timestamp * 1000,
|
||||
resolvedTimezone,
|
||||
dateFormat,
|
||||
);
|
||||
}, [
|
||||
resolvedTimezone,
|
||||
uPlotInstance.data,
|
||||
|
||||
@@ -145,6 +145,8 @@ export interface LegendProps {
|
||||
/** Legend placement; always supplied by the container. */
|
||||
position: LegendPosition;
|
||||
averageLegendWidth?: number;
|
||||
/** Set by the chart layout, which reserves the height for it. */
|
||||
showSearch?: boolean;
|
||||
/** Series index highlighted by the chart cursor. */
|
||||
focusedSeriesIndex: number | null;
|
||||
onAction: OnLegendAction;
|
||||
@@ -158,6 +160,7 @@ export interface UPlotLegendProps {
|
||||
position?: LegendPosition;
|
||||
config: UPlotConfigBuilder;
|
||||
averageLegendWidth?: number;
|
||||
showSearch?: boolean;
|
||||
}
|
||||
|
||||
export interface TooltipContentItem {
|
||||
|
||||
@@ -265,7 +265,7 @@ function getPathBuilder({
|
||||
drawStyle,
|
||||
lineInterpolation,
|
||||
barAlignment = BarAlignment.Center,
|
||||
barWidthFactor = 0.6,
|
||||
barWidthFactor = 0.85,
|
||||
barMaxWidth = 200,
|
||||
stepInterval,
|
||||
}: {
|
||||
|
||||
@@ -297,7 +297,7 @@ describe('UPlotSeriesBuilder', () => {
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
expect(config.stroke).toBe('#E64A3C');
|
||||
expect(config.stroke).toBe('#AD42E0');
|
||||
});
|
||||
|
||||
it('passes through pointsFilter when provided', () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import ChartLayout from 'lib/visualization/layout/ChartLayout/ChartLayout';
|
||||
import ChartLayout, {
|
||||
LegendLayout,
|
||||
} from 'lib/visualization/layout/ChartLayout/ChartLayout';
|
||||
import UPlotLegend from 'lib/uPlotV2/components/Legend/UPlotLegend';
|
||||
import {
|
||||
LegendPosition,
|
||||
@@ -58,7 +60,7 @@ export default function ChartWrapper({
|
||||
);
|
||||
|
||||
const legendComponent = useCallback(
|
||||
(averageLegendWidth: number): React.ReactNode => {
|
||||
({ averageLegendWidth, showSearch }: LegendLayout): React.ReactNode => {
|
||||
if (!showLegend) {
|
||||
return null;
|
||||
}
|
||||
@@ -67,6 +69,7 @@ export default function ChartWrapper({
|
||||
config={config}
|
||||
position={legendConfig.position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
showSearch={showSearch}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -68,17 +68,23 @@ export default function Pie({
|
||||
|
||||
// Reuse the uPlot chart/legend split so the donut + legend get the same area
|
||||
// allocation (right column, or up-to-two bottom rows) as every other panel.
|
||||
const { width, height, legendWidth, legendHeight, averageLegendWidth } =
|
||||
useMemo(
|
||||
() =>
|
||||
calculateChartDimensions({
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
legendConfig: { position },
|
||||
seriesLabels: data.map((slice) => slice.label),
|
||||
}),
|
||||
[containerWidth, containerHeight, position, data],
|
||||
);
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
legendWidth,
|
||||
legendHeight,
|
||||
averageLegendWidth,
|
||||
showLegendSearch,
|
||||
} = useMemo(
|
||||
() =>
|
||||
calculateChartDimensions({
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
legendConfig: { position },
|
||||
seriesLabels: data.map((slice) => slice.label),
|
||||
}),
|
||||
[containerWidth, containerHeight, position, data],
|
||||
);
|
||||
|
||||
// Donut geometry derived from the allocated chart box, sized to leave room
|
||||
// for the external leader labels (see getDonutGeometry).
|
||||
@@ -224,6 +230,7 @@ export default function Pie({
|
||||
items={legendItems}
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
showSearch={showLegendSearch}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onAction={onLegendAction}
|
||||
/>
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('calculateChartDimensions', () => {
|
||||
legendWidth: 0,
|
||||
legendHeight: 0,
|
||||
averageLegendWidth: 0,
|
||||
showLegendSearch: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,10 +107,10 @@ describe('calculateChartDimensions', () => {
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
// Two 28px rows + the 2px row gap + 12px bottom padding — no room for a
|
||||
// clipped third row, and none left over.
|
||||
expect(dims.legendHeight).toBe(70);
|
||||
expect(dims.height).toBe(430);
|
||||
// Two 28px rows + 2px gap + 12px padding, plus the 24px search row + 4px.
|
||||
expect(dims.showLegendSearch).toBe(true);
|
||||
expect(dims.legendHeight).toBe(98);
|
||||
expect(dims.height).toBe(402);
|
||||
});
|
||||
|
||||
it('BOTTOM: items one past a row still reserve two rows', () => {
|
||||
@@ -123,6 +124,50 @@ describe('calculateChartDimensions', () => {
|
||||
expect(dims.legendHeight).toBe(70);
|
||||
});
|
||||
|
||||
it('BOTTOM: no search row while every item is already on screen', () => {
|
||||
// 1000px fits 4 per row, so 8 items fill both reserved rows exactly.
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(8),
|
||||
});
|
||||
expect(dims.showLegendSearch).toBe(false);
|
||||
expect(dims.legendHeight).toBe(70);
|
||||
});
|
||||
|
||||
it('BOTTOM: a search row once the grid overflows the reserved rows', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(9),
|
||||
});
|
||||
expect(dims.showLegendSearch).toBe(true);
|
||||
expect(dims.legendHeight).toBe(98);
|
||||
});
|
||||
|
||||
it('BOTTOM: no search row when it would push the legend past a short panel', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 120,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
expect(dims.showLegendSearch).toBe(false);
|
||||
expect(dims.legendHeight).toBe(40);
|
||||
});
|
||||
|
||||
it('RIGHT: always carries its chrome; the column has the height for it', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
expect(dims.showLegendSearch).toBe(true);
|
||||
});
|
||||
|
||||
it('BOTTOM: reserves the rows the grid actually lays out, not the rows a bare width estimate allows', () => {
|
||||
// The item width alone suggests three fit on one row; the grid's per-item
|
||||
// padding and column gap leave room for two.
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
LEGEND_MAX_BOTTOM_ROWS,
|
||||
MIN_LEGEND_ITEM_WIDTH,
|
||||
LEGEND_COLUMN_GAP,
|
||||
LEGEND_TOOLBAR_GAP,
|
||||
LEGEND_TOOLBAR_HEIGHT,
|
||||
LEGEND_ITEM_EXTRA_WIDTH,
|
||||
LEGEND_ROW_GAP,
|
||||
LEGEND_ROW_HEIGHT,
|
||||
@@ -15,6 +17,8 @@ export interface ChartDimensions {
|
||||
legendWidth: number;
|
||||
legendHeight: number;
|
||||
averageLegendWidth: number;
|
||||
/** For a BOTTOM legend that row's height is inside `legendHeight`. */
|
||||
showLegendSearch: boolean;
|
||||
}
|
||||
|
||||
const AVG_CHAR_WIDTH = 8;
|
||||
@@ -76,6 +80,8 @@ export function calculateAverageLegendWidth(legends: string[]): number {
|
||||
* - `legendHeight` is exactly those rows plus the wrapper's bottom padding, so
|
||||
* the rectangle never clips a row or reserves space for half of one. Two
|
||||
* rows that would take half a short panel fall back to one row.
|
||||
* - A grid overflowing those rows also gets a search row, whose height is
|
||||
* part of `legendHeight`.
|
||||
* - Chart height is `containerHeight - legendHeight`, never below 0.
|
||||
* - `legendsPerSet` is the number of legend items that fit horizontally, based on the same text-width approximation.
|
||||
*
|
||||
@@ -101,6 +107,7 @@ export function calculateChartDimensions({
|
||||
legendWidth: 0,
|
||||
legendHeight: 0,
|
||||
averageLegendWidth: 0,
|
||||
showLegendSearch: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -140,6 +147,7 @@ export function calculateChartDimensions({
|
||||
legendHeight: containerHeight,
|
||||
// Single vertical list on the right.
|
||||
averageLegendWidth: rightLegendWidth,
|
||||
showLegendSearch: legendItemCount > 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,18 +166,25 @@ export function calculateChartDimensions({
|
||||
),
|
||||
);
|
||||
|
||||
// The wrapper's bottom padding is inside this height (border-box).
|
||||
const heightForRows = (rowCount: number): number =>
|
||||
// The wrapper's bottom padding and the search row are inside this height.
|
||||
const heightForRows = (rowCount: number, withToolbar: boolean): number =>
|
||||
rowCount * LEGEND_ROW_HEIGHT +
|
||||
(rowCount - 1) * LEGEND_ROW_GAP +
|
||||
LEGEND_PADDING;
|
||||
LEGEND_PADDING +
|
||||
(withToolbar ? LEGEND_TOOLBAR_HEIGHT + LEGEND_TOOLBAR_GAP : 0);
|
||||
|
||||
const shortPanelBudget = containerHeight * MAX_SHORT_PANEL_LEGEND_RATIO;
|
||||
const gridRowCount = Math.ceil(legendItemCount / legendItemsPerRow);
|
||||
|
||||
// Only once rows overflow — below that every series is already on screen —
|
||||
// and only while the row it costs leaves the legend inside the panel's share.
|
||||
const showLegendSearch =
|
||||
gridRowCount > LEGEND_MAX_BOTTOM_ROWS &&
|
||||
heightForRows(1, true) <= shortPanelBudget;
|
||||
|
||||
const neededRowCount = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
LEGEND_MAX_BOTTOM_ROWS,
|
||||
Math.ceil(legendItemCount / legendItemsPerRow),
|
||||
),
|
||||
Math.min(LEGEND_MAX_BOTTOM_ROWS, gridRowCount),
|
||||
);
|
||||
|
||||
// Without this, short grid panels hand most of their area to the legend and
|
||||
@@ -177,11 +192,11 @@ export function calculateChartDimensions({
|
||||
// row's items are clipped rather than removed, so they are scroll-only here.
|
||||
const legendRowCount =
|
||||
neededRowCount > 1 &&
|
||||
heightForRows(neededRowCount) > containerHeight * MAX_SHORT_PANEL_LEGEND_RATIO
|
||||
heightForRows(neededRowCount, showLegendSearch) > shortPanelBudget
|
||||
? 1
|
||||
: neededRowCount;
|
||||
|
||||
const bottomLegendHeight = heightForRows(legendRowCount);
|
||||
const bottomLegendHeight = heightForRows(legendRowCount, showLegendSearch);
|
||||
|
||||
return {
|
||||
width: containerWidth,
|
||||
@@ -189,5 +204,6 @@ export function calculateChartDimensions({
|
||||
legendWidth: containerWidth,
|
||||
legendHeight: bottomLegendHeight,
|
||||
averageLegendWidth: legendItemWidth,
|
||||
showLegendSearch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,9 +7,14 @@ import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
|
||||
import 'lib/visualization/layout/ChartLayout/ChartLayout.styles.scss';
|
||||
|
||||
export interface LegendLayout {
|
||||
averageLegendWidth: number;
|
||||
showSearch: boolean;
|
||||
}
|
||||
|
||||
export interface ChartLayoutProps {
|
||||
showLegend?: boolean;
|
||||
legendComponent: (legendPerSet: number) => React.ReactNode;
|
||||
legendComponent: (layout: LegendLayout) => React.ReactNode;
|
||||
children: (props: {
|
||||
chartWidth: number;
|
||||
chartHeight: number;
|
||||
@@ -40,6 +45,7 @@ export default function ChartLayout({
|
||||
legendWidth: 0,
|
||||
legendHeight: 0,
|
||||
averageLegendWidth: MAX_LEGEND_WIDTH,
|
||||
showLegendSearch: false,
|
||||
};
|
||||
}
|
||||
const legendItemsMap = config.getLegendItems();
|
||||
@@ -81,7 +87,10 @@ export default function ChartLayout({
|
||||
width: chartDimensions.legendWidth,
|
||||
}}
|
||||
>
|
||||
{legendComponent(chartDimensions.averageLegendWidth)}
|
||||
{legendComponent({
|
||||
averageLegendWidth: chartDimensions.averageLegendWidth,
|
||||
showSearch: chartDimensions.showLegendSearch,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
.all-errors-page {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
.all-errors-quick-filter-section {
|
||||
width: 0%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.all-errors-right-section {
|
||||
.right-toolbar-actions-container {
|
||||
display: flex;
|
||||
@@ -18,14 +11,4 @@
|
||||
.ant-tabs {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
.all-errors-quick-filter-section {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.all-errors-right-section {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,11 @@ import { Filter } from '@signozhq/icons';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import cx from 'classnames';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import QuickFiltersLayout from 'components/QuickFilters/QuickFiltersLayout/QuickFiltersLayout';
|
||||
import { useSignalFieldApis } from 'components/QuickFilters/hooks/useSignalFieldApis';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import RouteTab from 'components/RouteTab';
|
||||
import TypicalOverlayScrollbar from 'components/TypicalOverlayScrollbar/TypicalOverlayScrollbar';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import ResourceAttributesFilterV2 from 'container/ResourceAttributeFilterV2/ResourceAttributesFilterV2';
|
||||
@@ -59,63 +57,52 @@ function AllErrors(): JSX.Element {
|
||||
const quickFilterFieldApis = useSignalFieldApis();
|
||||
|
||||
return (
|
||||
<div className={cx('all-errors-page', showFilters ? 'filter-visible' : '')}>
|
||||
{showFilters && (
|
||||
<section className={cx('all-errors-quick-filter-section')}>
|
||||
<QuickFilters
|
||||
className="qf-exceptions"
|
||||
source={QuickFiltersSource.EXCEPTIONS}
|
||||
signal={SignalType.EXCEPTIONS}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
useFieldApis={quickFilterFieldApis}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
<section
|
||||
className={cx(
|
||||
'all-errors-right-section',
|
||||
showFilters ? 'filter-visible' : '',
|
||||
)}
|
||||
>
|
||||
<TypicalOverlayScrollbar>
|
||||
<>
|
||||
<Toolbar
|
||||
showAutoRefresh={false}
|
||||
leftActions={
|
||||
!showFilters ? (
|
||||
<Tooltip title="Show Filters">
|
||||
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
|
||||
<Filter size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
rightActions={
|
||||
<div className="right-toolbar-actions-container">
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={handleRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<QuickFiltersLayout
|
||||
className="all-errors-page"
|
||||
contentClassName="all-errors-right-section"
|
||||
showFilters={showFilters}
|
||||
quickFilterProps={{
|
||||
className: 'qf-exceptions',
|
||||
source: QuickFiltersSource.EXCEPTIONS,
|
||||
signal: SignalType.EXCEPTIONS,
|
||||
handleFilterVisibilityChange,
|
||||
useFieldApis: quickFilterFieldApis,
|
||||
}}
|
||||
>
|
||||
<Toolbar
|
||||
showAutoRefresh={false}
|
||||
leftActions={
|
||||
!showFilters ? (
|
||||
<Tooltip title="Show Filters">
|
||||
<Button onClick={handleFilterVisibilityChange} className="filter-btn">
|
||||
<Filter size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
rightActions={
|
||||
<div className="right-toolbar-actions-container">
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={handleRunQuery}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
<ResourceAttributesFilterV2 />
|
||||
<RouteTab
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
showRightSection={false}
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
/>
|
||||
</>
|
||||
</TypicalOverlayScrollbar>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ResourceAttributesFilterV2 />
|
||||
<RouteTab
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
showRightSection={false}
|
||||
/>
|
||||
</QuickFiltersLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -98,17 +98,30 @@ export const QuickFiltersSettings: Story = {
|
||||
play: openQuickFiltersSettings,
|
||||
};
|
||||
|
||||
const dirtyQuickFiltersSettings = async (): Promise<void> => {
|
||||
await openQuickFiltersSettings();
|
||||
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
};
|
||||
|
||||
/** Settings with an unsaved filter removal and the fixed action footer. */
|
||||
export const QuickFiltersSettingsDirty: Story = {
|
||||
play: async (): Promise<void> => {
|
||||
await openQuickFiltersSettings();
|
||||
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
},
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
.api-monitoring-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
.ant-tabs {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
padding: 0 16px;
|
||||
margin-bottom: 0px;
|
||||
@@ -15,22 +8,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
display: flex;
|
||||
|
||||
.ant-tabs-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -13,9 +13,12 @@ function ApiMonitoringPage(): JSX.Element {
|
||||
const routes: TabRoutes[] = [Explorer];
|
||||
|
||||
return (
|
||||
<div className="api-monitoring-page">
|
||||
<RouteTab routes={routes} activeKey={pathname} history={history} />
|
||||
</div>
|
||||
<RouteTab
|
||||
className="api-monitoring-page"
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
QuickfiltertypesSourceDTO,
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { VIEWS } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
|
||||
@@ -24,7 +25,10 @@ import {
|
||||
toggleControl,
|
||||
} from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import {
|
||||
fieldKeysResponse,
|
||||
fieldValuesResponse,
|
||||
} from '@/storybook/msw/__story_mockdata__/fields';
|
||||
import { quickFiltersResponse } from '@/storybook/msw/__story_mockdata__/quickFilters';
|
||||
|
||||
import {
|
||||
@@ -317,6 +321,21 @@ export const apiMonitoringMocks = defineStoryMocks({
|
||||
})),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/keys',
|
||||
response.json((req) =>
|
||||
fieldKeysResponse(
|
||||
groupByAttributeKeys(req.url.searchParams.get('searchText') ?? '').map(
|
||||
({ key }) => key,
|
||||
),
|
||||
{
|
||||
signal: TelemetrytypesSignalDTO.traces,
|
||||
fieldContext: TelemetrytypesFieldContextDTO.attribute,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get(
|
||||
'http://localhost/api/v1/fields/values',
|
||||
response.json((req) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -59,6 +59,35 @@ export const PortDomain: Story = {
|
||||
/** The page fetches before it renders a filter, which outlasts the 1s default. */
|
||||
const untilLoaded = { timeout: 15_000 };
|
||||
|
||||
const openQuickFiltersSettings = async (): Promise<void> => {
|
||||
// The settings control renders disabled while its permission check is in
|
||||
// flight and is swapped for the enabled one once the check answers, so it is
|
||||
// looked up again on every attempt; a click on the disabled one is dropped in
|
||||
// silence.
|
||||
const control = await waitFor(() => {
|
||||
const settings = screen.getByTestId('settings-icon-container');
|
||||
|
||||
expect(settings).toBeEnabled();
|
||||
|
||||
return settings;
|
||||
}, untilLoaded);
|
||||
|
||||
await userEvent.click(control);
|
||||
await screen.findByText('Edit quick filters', undefined, untilLoaded);
|
||||
};
|
||||
|
||||
const dirtyQuickFiltersSettings = async (): Promise<void> => {
|
||||
await openQuickFiltersSettings();
|
||||
|
||||
// One Remove per added filter; the first row's is the one clicked.
|
||||
const [removeFilter] = await screen.findAllByRole('button', {
|
||||
name: 'Remove',
|
||||
});
|
||||
|
||||
await userEvent.click(removeFilter);
|
||||
await screen.findByRole('button', { name: 'Save changes' });
|
||||
};
|
||||
|
||||
/**
|
||||
* The quick-filter panel has no test id of its own, and it only mounts once the
|
||||
* workspace's filters have answered.
|
||||
@@ -143,3 +172,24 @@ export const NoExternalCalls: Story = {
|
||||
export const Loading: Story = {
|
||||
args: { dataState: 'loading' },
|
||||
};
|
||||
|
||||
/** The editable quick-filter settings panel. */
|
||||
export const QuickFiltersSettings: Story = {
|
||||
play: openQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/** Settings with an unsaved filter removal and the fixed action footer. */
|
||||
export const QuickFiltersSettingsDirty: Story = {
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
/**
|
||||
* The same panel with a banner above the shell. The banner takes 48px off the
|
||||
* layout, so this is the case where the footer used to be pushed off screen:
|
||||
* the panel is sized from the filters pane rather than the viewport, which
|
||||
* keeps Save changes reachable.
|
||||
*/
|
||||
export const QuickFiltersSettingsWithBanner: Story = {
|
||||
args: { banner: 'trial-expiry' },
|
||||
play: dirtyQuickFiltersSettings,
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ interface ConfigPaneProps {
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Switch the panel to another visualization kind. */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
originalPanelKind?: PanelKind;
|
||||
/**
|
||||
* Active query type from the query-builder provider (the selected tab). Drives which
|
||||
* panel types the visualization switcher disables — read from the provider, not the
|
||||
@@ -57,6 +58,7 @@ function ConfigPane({
|
||||
spec,
|
||||
onChangeSpec,
|
||||
onChangePanelKind,
|
||||
originalPanelKind,
|
||||
queryType,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
@@ -125,6 +127,7 @@ function ConfigPane({
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
@@ -149,6 +152,7 @@ function ConfigPane({
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
|
||||
@@ -1,6 +1,63 @@
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 5px 5px 5px 12px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
}
|
||||
|
||||
.triggerIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.triggerName {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--l1-foreground);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.triggerAction {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 3px;
|
||||
background: var(--l3-background);
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.revert {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.drawerBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ArrowRightLeft, Undo2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import PanelTypeBrowser from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeBrowser';
|
||||
import { getPanelDefinition } from '../../../Panels/registry';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import styles from './PanelTypeSwitcher.module.scss';
|
||||
import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
|
||||
interface PanelTypeSwitcherProps {
|
||||
/** The current panel kind (selected value). */
|
||||
@@ -15,32 +20,96 @@ interface PanelTypeSwitcherProps {
|
||||
queryType: EQueryType;
|
||||
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
/** Kind the panel was opened with; a revert button appears once it differs. */
|
||||
originalPanelKind?: PanelKind;
|
||||
onChange: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualization-type selector (rendered inside the Visualization section). A type is
|
||||
* disabled when the active query type or signal is incompatible with it — resolved
|
||||
* through the capabilities guard. The signal is unknown for PromQL/ClickHouse, but
|
||||
* those query types still disable kinds that only support Query Builder (e.g. List).
|
||||
* Visualization-type selector (rendered inside the Visualization section): opens the
|
||||
* panel type browser in a drawer. A type is disabled when the active query type or
|
||||
* signal is incompatible with it — resolved through the capabilities guard.
|
||||
*/
|
||||
function PanelTypeSwitcher({
|
||||
panelKind,
|
||||
queryType,
|
||||
signal,
|
||||
originalPanelKind,
|
||||
onChange,
|
||||
}: PanelTypeSwitcherProps): JSX.Element {
|
||||
const items = usePanelTypeSelectItems({ queryType, signal });
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { displayName, icon: Icon } = getPanelDefinition(panelKind);
|
||||
|
||||
const getDisabledReason = useCallback(
|
||||
(kind: PanelKind): string | undefined =>
|
||||
getPanelTypeDisabledReason({
|
||||
kind,
|
||||
queryType,
|
||||
signal,
|
||||
label: getPanelDefinition(kind).displayName,
|
||||
}),
|
||||
[queryType, signal],
|
||||
);
|
||||
|
||||
const canRevert = !!originalPanelKind && originalPanelKind !== panelKind;
|
||||
const revertBlockedReason = canRevert
|
||||
? getDisabledReason(originalPanelKind)
|
||||
: undefined;
|
||||
|
||||
const handleSelect = (kind: PanelKind): void => {
|
||||
setIsOpen(false);
|
||||
if (kind !== panelKind) {
|
||||
onChange(kind);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Panel Type</Typography.Text>
|
||||
<ConfigSelect
|
||||
testId="panel-editor-v2-type-switcher"
|
||||
value={panelKind}
|
||||
items={items}
|
||||
onChange={(value): void => onChange(value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
onClick={(): void => setIsOpen(true)}
|
||||
data-testid="panel-editor-v2-type-switcher"
|
||||
>
|
||||
<Icon size={14} className={styles.triggerIcon} />
|
||||
<span className={styles.triggerName}>{displayName}</span>
|
||||
<span className={styles.triggerAction}>
|
||||
<ArrowRightLeft size={14} />
|
||||
Change
|
||||
</span>
|
||||
</button>
|
||||
{canRevert && (
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<Undo2 />}
|
||||
className={styles.revert}
|
||||
disabled={!!revertBlockedReason}
|
||||
title={revertBlockedReason}
|
||||
onClick={(): void => onChange(originalPanelKind)}
|
||||
testId="panel-editor-v2-type-revert"
|
||||
>
|
||||
Revert to {getPanelDefinition(originalPanelKind).displayName}
|
||||
</Button>
|
||||
)}
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
title="Change panel type"
|
||||
subTitle="Pick a visualization for this panel."
|
||||
direction="right"
|
||||
width="wide"
|
||||
testId="panel-type-switcher-drawer"
|
||||
drawerDescriptionProps={{ className: styles.drawerBody }}
|
||||
>
|
||||
<PanelTypeBrowser
|
||||
selectedKind={panelKind}
|
||||
onSelect={handleSelect}
|
||||
getDisabledReason={getDisabledReason}
|
||||
/>
|
||||
</DrawerWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelTypeSwitcher from '../PanelTypeSwitcher';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
const OPTIONS = [
|
||||
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
|
||||
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
|
||||
{ kind: 'signoz/TablePanel', displayName: 'Table' },
|
||||
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
|
||||
{ kind: 'signoz/AreaChartPanel', displayName: 'Area' },
|
||||
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
|
||||
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
|
||||
{ kind: 'signoz/ListPanel', displayName: 'List' },
|
||||
{ kind: 'signoz/TextPanel', displayName: 'Text' },
|
||||
].map((option) => ({ ...option, icon: (): null => null }));
|
||||
|
||||
// Stub the registry so the test doesn't pull in the real renderers and chart libs.
|
||||
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
PANEL_OPTIONS: [
|
||||
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
|
||||
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
|
||||
{ kind: 'signoz/TablePanel', displayName: 'Table' },
|
||||
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
|
||||
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
|
||||
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
|
||||
{ kind: 'signoz/ListPanel', displayName: 'List' },
|
||||
].map((option) => ({ ...option, icon: (): null => null })),
|
||||
get PANEL_OPTIONS(): unknown {
|
||||
return OPTIONS;
|
||||
},
|
||||
}));
|
||||
|
||||
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
|
||||
@@ -28,14 +35,30 @@ const SUPPORTED_QUERY_TYPES: Record<string, EQueryType[]> = {
|
||||
'signoz/PieChartPanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
};
|
||||
|
||||
function disabledLabels(): (string | null)[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).map((el) => el.textContent);
|
||||
function renderSwitcher(
|
||||
props: Partial<Parameters<typeof PanelTypeSwitcher>[0]> = {},
|
||||
): jest.Mock {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-type-switcher'));
|
||||
return onChange;
|
||||
}
|
||||
|
||||
function openDropdown(): void {
|
||||
fireEvent.mouseDown(screen.getByRole('combobox'));
|
||||
function disabledKinds(): (string | undefined)[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll('[data-testid^="panel-type-signoz/"]'),
|
||||
)
|
||||
.filter((el) => el.getAttribute('aria-disabled') === 'true')
|
||||
.map((el) => el.getAttribute('data-testid')?.replace('panel-type-', ''));
|
||||
}
|
||||
|
||||
describe('PanelTypeSwitcher', () => {
|
||||
@@ -44,7 +67,8 @@ describe('PanelTypeSwitcher', () => {
|
||||
// List supports only logs/traces; every other kind also supports metrics.
|
||||
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
|
||||
mockGetPanelDefinition.mockImplementation((kind: string) => ({
|
||||
mode: 'query',
|
||||
...OPTIONS.find((option) => option.kind === kind),
|
||||
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
|
||||
supportedSignals:
|
||||
kind === 'signoz/ListPanel'
|
||||
? ['logs', 'traces']
|
||||
@@ -57,83 +81,92 @@ describe('PanelTypeSwitcher', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('fires onChange with the chosen plugin kind', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
it('shows the current type and switches to the chosen one', () => {
|
||||
const onChange = renderSwitcher();
|
||||
|
||||
openDropdown();
|
||||
fireEvent.click(screen.getByText('List'));
|
||||
expect(screen.getByTestId('panel-editor-v2-type-switcher')).toHaveTextContent(
|
||||
'Time SeriesChange',
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('panel-type-signoz/ListPanel'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
|
||||
});
|
||||
|
||||
it('disables types whose supported signals exclude the current signal', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
signal={TelemetrytypesSignalDTO.metrics}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
it('does not fire onChange when the current type is picked again', () => {
|
||||
const onChange = renderSwitcher();
|
||||
|
||||
openDropdown();
|
||||
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
|
||||
expect(disabledLabels()).toContain('List');
|
||||
expect(disabledLabels()).not.toContain('Time Series');
|
||||
fireEvent.click(screen.getByTestId('panel-type-signoz/TimeSeriesPanel'));
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables types whose supported signals exclude the current signal', () => {
|
||||
const onChange = renderSwitcher({ signal: TelemetrytypesSignalDTO.metrics });
|
||||
|
||||
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
|
||||
fireEvent.click(screen.getByTestId('panel-type-signoz/ListPanel'));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not disable any type when the signal is unknown (builder, no signal)', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
renderSwitcher();
|
||||
|
||||
openDropdown();
|
||||
expect(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).toHaveLength(0);
|
||||
expect(disabledKinds()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('disables Query-Builder-only kinds under PromQL even without a signal', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.PROM}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
renderSwitcher({ queryType: EQueryType.PROM });
|
||||
|
||||
openDropdown();
|
||||
// List/Table/Pie can't be authored in PromQL; Time Series can.
|
||||
expect(disabledLabels()).toContain('List');
|
||||
expect(disabledLabels()).toContain('Table');
|
||||
expect(disabledLabels()).toContain('Pie Chart');
|
||||
expect(disabledLabels()).not.toContain('Time Series');
|
||||
expect(disabledKinds()).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
'signoz/ListPanel',
|
||||
'signoz/TablePanel',
|
||||
'signoz/PieChartPanel',
|
||||
]),
|
||||
);
|
||||
expect(disabledKinds()).not.toContain('signoz/TimeSeriesPanel');
|
||||
expect(disabledKinds()).not.toContain('signoz/TextPanel');
|
||||
});
|
||||
|
||||
it('disables List under ClickHouse while Table/Pie stay enabled', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TablePanel"
|
||||
queryType={EQueryType.CLICKHOUSE}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
renderSwitcher({
|
||||
panelKind: 'signoz/TablePanel',
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
});
|
||||
|
||||
openDropdown();
|
||||
expect(disabledLabels()).toContain('List');
|
||||
expect(disabledLabels()).not.toContain('Table');
|
||||
expect(disabledLabels()).not.toContain('Pie Chart');
|
||||
expect(disabledLabels()).not.toContain('Time Series');
|
||||
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
|
||||
});
|
||||
|
||||
describe('revert', () => {
|
||||
it('is hidden while the type is the original one', () => {
|
||||
renderSwitcher({ originalPanelKind: 'signoz/TimeSeriesPanel' });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-type-revert'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches back to the original type', () => {
|
||||
const onChange = renderSwitcher({
|
||||
panelKind: 'signoz/TablePanel',
|
||||
originalPanelKind: 'signoz/TimeSeriesPanel',
|
||||
});
|
||||
|
||||
const revert = screen.getByTestId('panel-editor-v2-type-revert');
|
||||
expect(revert).toHaveTextContent('Revert to Time Series');
|
||||
fireEvent.click(revert);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('signoz/TimeSeriesPanel');
|
||||
});
|
||||
|
||||
it('is disabled when the original type no longer fits the query', () => {
|
||||
renderSwitcher({
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
originalPanelKind: 'signoz/ListPanel',
|
||||
queryType: EQueryType.PROM,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-type-revert')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,8 +18,7 @@ interface UsePanelTypeSelectItemsArgs {
|
||||
/**
|
||||
* Visualization-kind options for a `ConfigSelect`, each disabled (with a reason
|
||||
* tooltip) when the active query type or signal is incompatible — resolved through
|
||||
* the capabilities guard. Shared by the editor's `PanelTypeSwitcher` and the View
|
||||
* modal's header so the two selectors apply the same rule and can't drift.
|
||||
* the capabilities guard, the same rule the editor's `PanelTypeSwitcher` applies.
|
||||
*/
|
||||
export function usePanelTypeSelectItems({
|
||||
queryType,
|
||||
|
||||
@@ -58,6 +58,7 @@ function SectionSlot({
|
||||
signal,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
originalPanelKind,
|
||||
queryType,
|
||||
stepInterval,
|
||||
metricUnit,
|
||||
@@ -124,6 +125,7 @@ function SectionSlot({
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface SectionEditorContext {
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
panelKind?: PanelKind;
|
||||
onChangePanelKind?: (kind: PanelKind) => void;
|
||||
/** Kind the panel was opened with, offered as a revert target. */
|
||||
originalPanelKind?: PanelKind;
|
||||
yAxisUnit?: string;
|
||||
queryType?: EQueryType;
|
||||
stepInterval?: number;
|
||||
|
||||
@@ -19,7 +19,11 @@ import styles from './VisualizationSection.module.scss';
|
||||
type VisualizationSectionProps = SectionEditorProps<SectionKind.Visualization> &
|
||||
Pick<
|
||||
SectionEditorContext,
|
||||
'panelKind' | 'onChangePanelKind' | 'signal' | 'queryType'
|
||||
| 'panelKind'
|
||||
| 'onChangePanelKind'
|
||||
| 'originalPanelKind'
|
||||
| 'signal'
|
||||
| 'queryType'
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -35,6 +39,7 @@ function VisualizationSection({
|
||||
onChange,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
originalPanelKind,
|
||||
queryType,
|
||||
signal,
|
||||
}: VisualizationSectionProps): JSX.Element {
|
||||
@@ -47,6 +52,7 @@ function VisualizationSection({
|
||||
// supplied in practice; default to Query Builder at this boundary.
|
||||
queryType={queryType ?? EQueryType.QUERY_BUILDER}
|
||||
signal={signal}
|
||||
originalPanelKind={originalPanelKind}
|
||||
onChange={onChangePanelKind}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,8 @@ import VisualizationSection from '../VisualizationSection';
|
||||
// the test doesn't pull the whole panel registry (renderers, chart libs).
|
||||
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(() => ({
|
||||
displayName: 'Time Series',
|
||||
icon: (): null => null,
|
||||
mode: 'query',
|
||||
supportedSignals: ['metrics', 'logs', 'traces'],
|
||||
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
|
||||
@@ -173,7 +175,7 @@ describe('VisualizationSection', () => {
|
||||
expect(onChange).toHaveBeenCalledWith({ fillSpans: true });
|
||||
});
|
||||
|
||||
it('renders the type switcher and switches kind when switchPanelKind is set', async () => {
|
||||
it('renders the type switcher and switches kind when switchPanelKind is set', () => {
|
||||
const onChangePanelKind = jest.fn();
|
||||
render(
|
||||
<VisualizationSection
|
||||
@@ -189,7 +191,8 @@ describe('VisualizationSection', () => {
|
||||
screen.getByTestId('panel-editor-v2-type-switcher'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await pickOption('panel-editor-v2-type-switcher', 'Table');
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-type-switcher'));
|
||||
fireEvent.click(screen.getByTestId('panel-type-signoz/TablePanel'));
|
||||
expect(onChangePanelKind).toHaveBeenCalledWith('signoz/TablePanel');
|
||||
});
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import { useTableColumns } from './hooks/useTableColumns';
|
||||
|
||||
import logEvent from '@/api/common/logEvent';
|
||||
import { DashboardEvents } from '../../constants/events';
|
||||
import type { NewPanelTarget } from '../patchOps';
|
||||
|
||||
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
|
||||
// popups (group-by, order-by, having, …) clip when they open into the short pane.
|
||||
@@ -58,8 +59,7 @@ interface QueryEditorBodyProps {
|
||||
savedPanel?: DashboardtypesPanelDTO;
|
||||
/** Creating a new panel (seeded default) vs editing an existing one. */
|
||||
isNew?: boolean;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
layoutIndex?: number;
|
||||
target?: NewPanelTarget;
|
||||
/** Leave the editor (navigate back to the dashboard) without saving. */
|
||||
onClose: () => void;
|
||||
/** Called after a successful save — navigates back to the dashboard. */
|
||||
@@ -70,6 +70,7 @@ interface QueryEditorBodyProps {
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
originalPanelKind?: PanelKind;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,12 +85,13 @@ function QueryEditorBody({
|
||||
panel,
|
||||
savedPanel,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
target,
|
||||
onClose,
|
||||
onSaved,
|
||||
draftApi,
|
||||
panelDefinition,
|
||||
onChangePanelKind,
|
||||
originalPanelKind,
|
||||
}: QueryEditorBodyProps): JSX.Element {
|
||||
// Read here rather than taken as props: this renders inside a loaded dashboard
|
||||
// subtree, so it resolves the same context every other consumer does.
|
||||
@@ -133,7 +135,7 @@ function QueryEditorBody({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew,
|
||||
layoutIndex,
|
||||
target,
|
||||
});
|
||||
|
||||
const panelKind = draft.spec.plugin.kind;
|
||||
@@ -319,6 +321,7 @@ function QueryEditorBody({
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
queryType={currentQuery.queryType}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
|
||||
@@ -23,6 +23,7 @@ interface StaticEditorBodyProps extends PanelEditorContainerProps {
|
||||
draftApi: PanelEditorDraftApi;
|
||||
panelDefinition: RenderableStaticPanelDefinition;
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
originalPanelKind?: PanelKind;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,12 +36,13 @@ function StaticEditorBody({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
target,
|
||||
onClose,
|
||||
onSaved,
|
||||
draftApi,
|
||||
panelDefinition,
|
||||
onChangePanelKind,
|
||||
originalPanelKind,
|
||||
}: StaticEditorBodyProps): JSX.Element {
|
||||
// Read here rather than taken as props: this renders inside a loaded dashboard
|
||||
// subtree, so it resolves the same context every other consumer does.
|
||||
@@ -54,7 +56,7 @@ function StaticEditorBody({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew,
|
||||
layoutIndex,
|
||||
target,
|
||||
});
|
||||
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
@@ -122,6 +124,7 @@ function StaticEditorBody({
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
legendSeries={[]}
|
||||
tableColumns={[]}
|
||||
|
||||
@@ -6,14 +6,33 @@ import {
|
||||
NEW_PANEL_ID,
|
||||
newPanelSearch,
|
||||
parseNewPanelKind,
|
||||
parseNewPanelLayoutIndex,
|
||||
parseNewPanelTarget,
|
||||
} from '../newPanelRoute';
|
||||
|
||||
describe('newPanelRoute', () => {
|
||||
it('round-trips kind + layoutIndex through the new-panel search', () => {
|
||||
const search = newPanelSearch('signoz/ListPanel', 2);
|
||||
const search = newPanelSearch('signoz/ListPanel', {
|
||||
type: 'section',
|
||||
layoutIndex: 2,
|
||||
});
|
||||
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe('signoz/ListPanel');
|
||||
expect(parseNewPanelLayoutIndex(search)).toBe(2);
|
||||
expect(parseNewPanelTarget(search)).toStrictEqual({
|
||||
type: 'section',
|
||||
layoutIndex: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ type: 'root' as const },
|
||||
{ type: 'newSection' as const, title: 'Errors & 5xx' },
|
||||
])('round-trips a $type target', (target) => {
|
||||
expect(
|
||||
parseNewPanelTarget(newPanelSearch('signoz/TimeSeriesPanel', target)),
|
||||
).toStrictEqual(target);
|
||||
});
|
||||
|
||||
it('ignores a blank new section title', () => {
|
||||
expect(parseNewPanelTarget('?newSection=%20%20')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('omits layoutIndex when not provided', () => {
|
||||
@@ -21,7 +40,7 @@ describe('newPanelRoute', () => {
|
||||
expect(parseNewPanelKind(NEW_PANEL_ID, search)).toBe(
|
||||
'signoz/TimeSeriesPanel',
|
||||
);
|
||||
expect(parseNewPanelLayoutIndex(search)).toBeUndefined();
|
||||
expect(parseNewPanelTarget(search)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns null for an existing panel id (not the new sentinel)', () => {
|
||||
|
||||
@@ -14,15 +14,14 @@ import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
|
||||
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
|
||||
import { transferColumnWidths } from '../../Panels/utils/columnWidthStorage';
|
||||
import { createPanelOps } from '../../patchOps';
|
||||
import { createPanelOps, type NewPanelTarget } from '../../patchOps';
|
||||
|
||||
interface UsePanelEditorSaveArgs {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
/** Creating a new panel (vs editing an existing one) — adds panel + layout. */
|
||||
isNew?: boolean;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
layoutIndex?: number;
|
||||
target?: NewPanelTarget;
|
||||
}
|
||||
|
||||
interface UsePanelEditorSaveApi {
|
||||
@@ -42,7 +41,7 @@ export function usePanelEditorSave({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
target,
|
||||
}: UsePanelEditorSaveArgs): UsePanelEditorSaveApi {
|
||||
const queryClient = useQueryClient();
|
||||
const { patchAsync, isPatching, error } = useOptimisticPatch(dashboardId);
|
||||
@@ -60,7 +59,7 @@ export function usePanelEditorSave({
|
||||
savedPanelId = uuid();
|
||||
ops = createPanelOps({
|
||||
layouts: cached?.data.spec.layouts ?? [],
|
||||
layoutIndex,
|
||||
target,
|
||||
panelId: savedPanelId,
|
||||
panel: { kind: DashboardtypesPanelKindDTO.Panel, spec },
|
||||
});
|
||||
@@ -89,7 +88,7 @@ export function usePanelEditorSave({
|
||||
});
|
||||
return savedPanelId;
|
||||
},
|
||||
[dashboardId, panelId, isNew, layoutIndex, patchAsync, queryClient],
|
||||
[dashboardId, panelId, isNew, target, patchAsync, queryClient],
|
||||
);
|
||||
|
||||
return { save, isSaving: isPatching, error };
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
import type { NewPanelTarget } from '../patchOps';
|
||||
import QueryEditorBody from './QueryEditorBody';
|
||||
import StaticEditorBody from './StaticEditorBody';
|
||||
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
|
||||
@@ -18,8 +19,7 @@ export interface PanelEditorContainerProps {
|
||||
savedPanel?: DashboardtypesPanelDTO;
|
||||
/** Creating a new panel (seeded default) vs editing an existing one. */
|
||||
isNew?: boolean;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
layoutIndex?: number;
|
||||
target?: NewPanelTarget;
|
||||
/** Leave the editor (navigate back to the dashboard) without saving. */
|
||||
onClose: () => void;
|
||||
/** Called after a successful save — navigates back to the dashboard. */
|
||||
@@ -38,6 +38,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
|
||||
|
||||
const panelKind = draftApi.draft.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
const originalPanelKind = (savedPanel ?? panel).spec.plugin.kind;
|
||||
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
spec: draftApi.draft.spec,
|
||||
@@ -52,6 +53,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
|
||||
draftApi={draftApi}
|
||||
panelDefinition={panelDefinition}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -62,6 +64,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
|
||||
draftApi={draftApi}
|
||||
panelDefinition={panelDefinition}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,26 +4,33 @@ import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import type { NewPanelTarget } from '../patchOps';
|
||||
import { PANELS } from '../Panels/registry';
|
||||
import {
|
||||
PANEL_TYPE_TO_PANEL_KIND,
|
||||
type PanelKind,
|
||||
} from '../Panels/types/panelKind';
|
||||
|
||||
// New (unsaved) panels use a fixed id segment, carrying kind + target section in the
|
||||
// query (`/panel/new?panelKind=…&layoutIndex=…`); the real id is generated on save.
|
||||
// New (unsaved) panels use a fixed id segment, carrying kind + target in the query
|
||||
// (`/panel/new?panelKind=…&layoutIndex=<index|root>` or `&newSection=<title>`).
|
||||
export const NEW_PANEL_ID = 'new';
|
||||
const PANEL_KIND_PARAM = 'panelKind';
|
||||
const LAYOUT_INDEX_PARAM = 'layoutIndex';
|
||||
const NEW_SECTION_PARAM = 'newSection';
|
||||
const ROOT_LAYOUT = 'root';
|
||||
|
||||
/** Query string (incl. leading `?`) for the new-panel editor route. */
|
||||
export function newPanelSearch(
|
||||
panelKind: PanelKind,
|
||||
layoutIndex?: number,
|
||||
target?: NewPanelTarget,
|
||||
): string {
|
||||
const params = new URLSearchParams({ [PANEL_KIND_PARAM]: panelKind });
|
||||
if (layoutIndex !== undefined) {
|
||||
params.set(LAYOUT_INDEX_PARAM, String(layoutIndex));
|
||||
if (target?.type === 'section') {
|
||||
params.set(LAYOUT_INDEX_PARAM, String(target.layoutIndex));
|
||||
} else if (target?.type === 'root') {
|
||||
params.set(LAYOUT_INDEX_PARAM, ROOT_LAYOUT);
|
||||
} else if (target?.type === 'newSection') {
|
||||
params.set(NEW_SECTION_PARAM, target.title);
|
||||
}
|
||||
return `?${params.toString()}`;
|
||||
}
|
||||
@@ -75,12 +82,21 @@ export function buildExportPanelLink({
|
||||
}=${encodeURIComponent(encodeURIComponent(JSON.stringify(query)))}`;
|
||||
}
|
||||
|
||||
/** Target section index for a new panel, or undefined when unset/invalid. */
|
||||
export function parseNewPanelLayoutIndex(search: string): number | undefined {
|
||||
const raw = new URLSearchParams(search).get(LAYOUT_INDEX_PARAM);
|
||||
export function parseNewPanelTarget(
|
||||
search: string,
|
||||
): NewPanelTarget | undefined {
|
||||
const params = new URLSearchParams(search);
|
||||
const title = params.get(NEW_SECTION_PARAM)?.trim();
|
||||
if (title) {
|
||||
return { type: 'newSection', title };
|
||||
}
|
||||
const raw = params.get(LAYOUT_INDEX_PARAM);
|
||||
if (raw === ROOT_LAYOUT) {
|
||||
return { type: 'root' };
|
||||
}
|
||||
if (raw === null || raw === '') {
|
||||
return undefined;
|
||||
}
|
||||
const n = Number(raw);
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
return Number.isNaN(n) ? undefined : { type: 'section', layoutIndex: n };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
.browser {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: sticky;
|
||||
// Cover the drawer body's top padding so tiles don't peek above when stuck.
|
||||
top: -16px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-top: -16px;
|
||||
padding-block: 16px;
|
||||
border-bottom: 1px dashed var(--l1-border);
|
||||
// Matches the drawer surface, which the app pins to l1 for every drawer.
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.search {
|
||||
box-sizing: border-box;
|
||||
--input-wrapper-background: var(--l2-background);
|
||||
--input-wrapper-border-color: var(--l2-border);
|
||||
--input-hover-border-color: var(--l3-border);
|
||||
--input-box-shadow: none;
|
||||
}
|
||||
|
||||
.categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.category {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 11px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--l2-foreground);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.categoryActive {
|
||||
border-color: var(--l3-border);
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.categoryCount {
|
||||
color: var(--l3-foreground);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
.groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
color: var(--bg-sienna-400);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 200ms cubic-bezier(0.08, 0.52, 0.52, 1),
|
||||
border-color 200ms cubic-bezier(0.08, 0.52, 0.52, 1);
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
.tileSelected {
|
||||
border-color: var(--bg-robin-500);
|
||||
box-shadow: inset 0 0 0 1px var(--bg-robin-500);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.tileDisabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
}
|
||||
|
||||
.tileText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.tileTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.tileName {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.newBadge {
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--bg-sienna-400);
|
||||
border-radius: 3px;
|
||||
color: var(--bg-sienna-400);
|
||||
font-size: 9.5px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tileDescription {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 48px 0;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.clearSearch {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--bg-robin-500);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-robin-400);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Search } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import {
|
||||
filterPanelTypeGroups,
|
||||
type PanelTypeGroupId,
|
||||
} from './panelTypeCatalog';
|
||||
import PanelTypeTile from './PanelTypeTile';
|
||||
|
||||
import styles from './PanelTypeBrowser.module.scss';
|
||||
|
||||
type CategoryId = PanelTypeGroupId | 'all';
|
||||
|
||||
interface PanelTypeBrowserProps {
|
||||
selectedKind: PanelKind;
|
||||
onSelect: (kind: PanelKind) => void;
|
||||
getDisabledReason?: (kind: PanelKind) => string | undefined;
|
||||
}
|
||||
|
||||
/** Searchable, category-filtered grid of panel types, grouped by purpose. */
|
||||
function PanelTypeBrowser({
|
||||
selectedKind,
|
||||
onSelect,
|
||||
getDisabledReason,
|
||||
}: PanelTypeBrowserProps): JSX.Element {
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState<CategoryId>('all');
|
||||
|
||||
const matched = useMemo(() => filterPanelTypeGroups(query), [query]);
|
||||
|
||||
const categories = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'all' as const,
|
||||
label: 'All',
|
||||
count: matched.reduce((sum, group) => sum + group.items.length, 0),
|
||||
},
|
||||
...matched.map(({ id, label, items }) => ({
|
||||
id,
|
||||
label,
|
||||
count: items.length,
|
||||
})),
|
||||
],
|
||||
[matched],
|
||||
);
|
||||
|
||||
const visibleGroups = matched.filter(
|
||||
(group) =>
|
||||
group.items.length > 0 && (category === 'all' || group.id === category),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.browser}>
|
||||
<div className={styles.controls}>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e): void => setQuery(e.target.value)}
|
||||
placeholder="Search panel types"
|
||||
prefix={<Search size={14} />}
|
||||
testId="panel-type-search"
|
||||
containerClassName={styles.search}
|
||||
/>
|
||||
<div className={styles.categories}>
|
||||
{categories.map(({ id, label, count }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={cx(styles.category, {
|
||||
[styles.categoryActive]: category === id,
|
||||
})}
|
||||
aria-pressed={category === id}
|
||||
onClick={(): void => setCategory(id)}
|
||||
>
|
||||
{label}
|
||||
<span className={styles.categoryCount}>{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.groups}>
|
||||
{visibleGroups.map((group) => (
|
||||
<section key={group.id} className={styles.group}>
|
||||
<span className={styles.groupLabel}>{group.label}</span>
|
||||
<div className={styles.grid}>
|
||||
{group.items.map((item) => (
|
||||
<PanelTypeTile
|
||||
key={item.kind}
|
||||
item={item}
|
||||
isSelected={item.kind === selectedKind}
|
||||
disabledReason={getDisabledReason?.(item.kind)}
|
||||
onSelect={(): void => onSelect(item.kind)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{visibleGroups.length === 0 && (
|
||||
<div className={styles.empty}>
|
||||
<span>No panel types match “{query}”</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.clearSearch}
|
||||
onClick={(): void => setQuery('')}
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelTypeBrowser;
|
||||
@@ -0,0 +1,124 @@
|
||||
.preview {
|
||||
height: 66px;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bars,
|
||||
.histogram {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bars {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.histogram {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex: 1;
|
||||
border-radius: 1px;
|
||||
background: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
.number {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.numberValue {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 21px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.numberUnit {
|
||||
color: var(--l3-foreground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.numberLabel {
|
||||
color: var(--l3-foreground);
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tableRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr 0.7fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.listRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
.cell {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.cell,
|
||||
.headCell,
|
||||
.accentCell {
|
||||
display: block;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.cell {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.headCell {
|
||||
background: var(--l3-border);
|
||||
}
|
||||
|
||||
.accentCell {
|
||||
background: var(--bg-robin-500);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.dot,
|
||||
.accentDot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dot {
|
||||
background: var(--l3-border);
|
||||
}
|
||||
|
||||
.accentDot {
|
||||
background: var(--bg-robin-500);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPE_PREVIEWS } from './panelTypePreviews';
|
||||
|
||||
import styles from './PanelTypePreview.module.scss';
|
||||
|
||||
function PanelTypePreview({ kind }: { kind: PanelKind }): JSX.Element {
|
||||
return (
|
||||
<div className={styles.preview} aria-hidden>
|
||||
{PANEL_TYPE_PREVIEWS[kind]}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelTypePreview;
|
||||
@@ -1,69 +1,33 @@
|
||||
.panelTypeSection {
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.grid {
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panelTypeCard {
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
border-radius: 4px;
|
||||
color: var(--l1-foreground);
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
border-color 180ms ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--l2-background-hover);
|
||||
border-color: var(--bg-robin-400);
|
||||
}
|
||||
&:active {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
}
|
||||
|
||||
.panelTypeCardSelected {
|
||||
border-color: var(--bg-robin-400);
|
||||
background-color: var(--l2-background-hover);
|
||||
box-shadow: inset 0 0 0 1px var(--bg-robin-400);
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footerPicker {
|
||||
// Take all the width left over by the (natural-width) confirm button.
|
||||
.summary {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
color: var(--l3-foreground);
|
||||
font-size: 12.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pickerLabel {
|
||||
color: var(--l3-foreground);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
.summaryKind {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import cx from 'classnames';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
|
||||
import { useDashboardSections } from '../../../hooks/useDashboardSections';
|
||||
import { PANEL_OPTIONS } from '../../../Panels/registry';
|
||||
import { releasePanelPickerTarget } from '../../../store/usePanelPickerTargetStore';
|
||||
import { getPanelDefinition } from '../../../Panels/registry';
|
||||
import type { NewPanelTarget } from '../../../patchOps';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import PanelTypeSelectionModalFooter from './PanelTypeSelectionModalFooter';
|
||||
import PanelTypeBrowser from './PanelTypeBrowser';
|
||||
import SectionTarget from './SectionTarget';
|
||||
import { usePanelPickerDraftSection } from './usePanelPickerDraftSection';
|
||||
import { usePanelPickerTarget } from './usePanelPickerTarget';
|
||||
import { buildSectionOptions, resolveDefaultSectionValue } from './utils';
|
||||
|
||||
import styles from './PanelTypeSelectionModal.module.scss';
|
||||
|
||||
const DEFAULT_PANEL_KIND: PanelKind = 'signoz/TimeSeriesPanel';
|
||||
|
||||
interface PanelTypeSelectionModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (panelKind: PanelKind, layoutIndex?: number) => void;
|
||||
onSelect: (panelKind: PanelKind, target?: NewPanelTarget) => void;
|
||||
/** Section the picker opens on; omit → the first section. */
|
||||
defaultLayoutIndex?: number;
|
||||
}
|
||||
@@ -26,89 +34,105 @@ function PanelTypeSelectionModal({
|
||||
}: PanelTypeSelectionModalProps): JSX.Element {
|
||||
const sections = useDashboardSections();
|
||||
const options = useMemo(() => buildSectionOptions(sections), [sections]);
|
||||
|
||||
// With more than one section the user must pick a target section, so we keep
|
||||
// the select-then-confirm flow. Otherwise there's nothing to choose: hide the
|
||||
// footer and let a tile click create the panel outright.
|
||||
const hasSectionPicker = options.length > 1;
|
||||
|
||||
const [selectedValue, setSelectedValue] = useState('');
|
||||
const [selectedPanelKind, setSelectedPanelKind] = useState<PanelKind | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedKind, setSelectedKind] =
|
||||
useState<PanelKind>(DEFAULT_PANEL_KIND);
|
||||
const [newSectionTitle, setNewSectionTitle] = useState<string | null>(null);
|
||||
const isCreatingSection = newSectionTitle !== null;
|
||||
|
||||
// Seed the target section on open.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedValue(resolveDefaultSectionValue(options, defaultLayoutIndex));
|
||||
setSelectedPanelKind(null);
|
||||
setSelectedKind(DEFAULT_PANEL_KIND);
|
||||
setNewSectionTitle(null);
|
||||
}
|
||||
}, [open, options, defaultLayoutIndex]);
|
||||
|
||||
const createPanel = (panelKind: PanelKind): void => {
|
||||
const layoutIndex = selectedValue === '' ? undefined : Number(selectedValue);
|
||||
onSelect(panelKind, layoutIndex);
|
||||
const selectedTarget = options.find((o) => o.value === selectedValue)?.target;
|
||||
const selectedLayoutIndex =
|
||||
selectedTarget?.type === 'section' ? selectedTarget.layoutIndex : undefined;
|
||||
usePanelPickerTarget({
|
||||
open: open && !isCreatingSection,
|
||||
layoutIndex: selectedLayoutIndex,
|
||||
panelKind: selectedKind,
|
||||
outline: hasSectionPicker,
|
||||
});
|
||||
usePanelPickerDraftSection(newSectionTitle, selectedKind, open);
|
||||
|
||||
const handleClose = (): void => {
|
||||
releasePanelPickerTarget(true);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleTileClick = (panelKind: PanelKind): void => {
|
||||
if (hasSectionPicker) {
|
||||
setSelectedPanelKind(panelKind);
|
||||
return;
|
||||
}
|
||||
createPanel(panelKind);
|
||||
};
|
||||
const sectionTitle = newSectionTitle?.trim() ?? '';
|
||||
|
||||
const handleConfirm = (): void => {
|
||||
if (selectedPanelKind === null) {
|
||||
if (isCreatingSection && !sectionTitle) {
|
||||
return;
|
||||
}
|
||||
createPanel(selectedPanelKind);
|
||||
releasePanelPickerTarget(false);
|
||||
onSelect(
|
||||
selectedKind,
|
||||
isCreatingSection
|
||||
? { type: 'newSection', title: sectionTitle }
|
||||
: selectedTarget,
|
||||
);
|
||||
};
|
||||
|
||||
const selectedName = getPanelDefinition(selectedKind).displayName;
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
<DrawerWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
title="New Panel"
|
||||
title="New panel"
|
||||
subTitle="Pick a visualization. You can change it later."
|
||||
direction="right"
|
||||
width="wide"
|
||||
testId="panel-type-drawer"
|
||||
drawerDescriptionProps={{ className: styles.body }}
|
||||
footer={
|
||||
hasSectionPicker ? (
|
||||
<PanelTypeSelectionModalFooter
|
||||
options={options}
|
||||
selectedValue={selectedValue}
|
||||
onSectionChange={setSelectedValue}
|
||||
isConfirmDisabled={selectedPanelKind === null}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
) : undefined
|
||||
<div className={styles.footer}>
|
||||
<span className={styles.summary}>
|
||||
<span className={styles.summaryKind}>{selectedName}</span>
|
||||
<SectionTarget
|
||||
options={options}
|
||||
value={selectedValue}
|
||||
onChange={setSelectedValue}
|
||||
newSectionTitle={newSectionTitle}
|
||||
onNewSectionTitleChange={setNewSectionTitle}
|
||||
onSubmit={handleConfirm}
|
||||
/>
|
||||
</span>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
prefix={<Plus size={16} />}
|
||||
onClick={handleConfirm}
|
||||
disabled={isCreatingSection && !sectionTitle}
|
||||
testId="panel-type-confirm"
|
||||
>
|
||||
Add panel
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.panelTypeSection}>
|
||||
{hasSectionPicker && (
|
||||
<span className={styles.pickerLabel}>Select panel type</span>
|
||||
)}
|
||||
<div className={styles.grid}>
|
||||
{PANEL_OPTIONS.map(({ kind, displayName, icon: Icon }) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
className={cx(styles.panelTypeCard, {
|
||||
[styles.panelTypeCardSelected]: kind === selectedPanelKind,
|
||||
})}
|
||||
data-testid={`panel-type-${kind}`}
|
||||
aria-pressed={kind === selectedPanelKind}
|
||||
onClick={(): void => handleTileClick(kind)}
|
||||
>
|
||||
<Icon size={24} color={Color.BG_ROBIN_400} />
|
||||
{displayName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
<PanelTypeBrowser selectedKind={selectedKind} onSelect={setSelectedKind} />
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import SectionPicker from './SectionPicker';
|
||||
import type { SectionOption } from './types';
|
||||
import styles from './PanelTypeSelectionModal.module.scss';
|
||||
|
||||
interface PanelTypeSelectionModalFooterProps {
|
||||
options: SectionOption[];
|
||||
selectedValue: string;
|
||||
onSectionChange: (value: string) => void;
|
||||
/** Disabled until a panel type is picked. */
|
||||
isConfirmDisabled: boolean;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer for the New Panel modal: an "Add panel to" section picker and the
|
||||
* confirm button. Only rendered when the dashboard has more than one section —
|
||||
* otherwise there's nothing to pick and a tile click creates the panel directly.
|
||||
*/
|
||||
function PanelTypeSelectionModalFooter({
|
||||
options,
|
||||
selectedValue,
|
||||
onSectionChange,
|
||||
isConfirmDisabled,
|
||||
onConfirm,
|
||||
}: PanelTypeSelectionModalFooterProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.footerActions}>
|
||||
<div className={styles.footerPicker}>
|
||||
<span className={styles.pickerLabel}>Add panel to</span>
|
||||
<SectionPicker
|
||||
options={options}
|
||||
value={selectedValue}
|
||||
onChange={onSectionChange}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
disabled={isConfirmDisabled}
|
||||
prefix={<Plus size={16} />}
|
||||
onClick={onConfirm}
|
||||
testId="panel-type-confirm"
|
||||
>
|
||||
Add Panel
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelTypeSelectionModalFooter;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { PanelTypeItem } from './panelTypeCatalog';
|
||||
import PanelTypePreview from './PanelTypePreview';
|
||||
|
||||
import styles from './PanelTypeBrowser.module.scss';
|
||||
|
||||
interface PanelTypeTileProps {
|
||||
item: PanelTypeItem;
|
||||
isSelected: boolean;
|
||||
/** Why the kind can't be picked; the tile is disabled when set. */
|
||||
disabledReason?: string;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function PanelTypeTile({
|
||||
item,
|
||||
isSelected,
|
||||
disabledReason,
|
||||
onSelect,
|
||||
}: PanelTypeTileProps): JSX.Element {
|
||||
const tile = (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(styles.tile, {
|
||||
[styles.tileSelected]: isSelected,
|
||||
[styles.tileDisabled]: !!disabledReason,
|
||||
})}
|
||||
data-testid={`panel-type-${item.kind}`}
|
||||
aria-pressed={isSelected}
|
||||
// aria-disabled, not disabled, so the reason tooltip still gets hover events.
|
||||
aria-disabled={!!disabledReason}
|
||||
onClick={disabledReason ? undefined : onSelect}
|
||||
>
|
||||
<PanelTypePreview kind={item.kind} />
|
||||
<span className={styles.tileText}>
|
||||
<span className={styles.tileTitle}>
|
||||
<span className={styles.tileName}>{item.displayName}</span>
|
||||
{item.isNew && <span className={styles.newBadge}>New</span>}
|
||||
</span>
|
||||
<span className={styles.tileDescription}>{item.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return disabledReason ? (
|
||||
<TooltipSimple title={disabledReason}>{tile}</TooltipSimple>
|
||||
) : (
|
||||
tile
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelTypeTile;
|
||||
@@ -0,0 +1,29 @@
|
||||
import styles from './PanelTypePreview.module.scss';
|
||||
|
||||
interface PreviewBarsProps {
|
||||
heights: number[];
|
||||
/** Fade each successive bar (ranked bars) instead of a uniform opacity. */
|
||||
fade: boolean;
|
||||
className: string;
|
||||
}
|
||||
|
||||
function PreviewBars({
|
||||
heights,
|
||||
fade,
|
||||
className,
|
||||
}: PreviewBarsProps): JSX.Element {
|
||||
return (
|
||||
<div className={className}>
|
||||
{heights.map((height, i) => (
|
||||
<div
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={i}
|
||||
className={styles.bar}
|
||||
style={{ height: `${height}%`, opacity: fade ? 1 - i * 0.15 : 0.75 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PreviewBars;
|
||||
@@ -0,0 +1,34 @@
|
||||
import styles from './PanelTypePreview.module.scss';
|
||||
|
||||
export interface PreviewCell {
|
||||
className: string;
|
||||
/** Percent of the row width. */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
interface PreviewRowsProps {
|
||||
rows: PreviewCell[][];
|
||||
rowClassName?: string;
|
||||
}
|
||||
|
||||
function PreviewRows({ rows, rowClassName }: PreviewRowsProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.rows}>
|
||||
{rows.map((cells, row) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={row} className={rowClassName}>
|
||||
{cells.map(({ className, width }, col) => (
|
||||
<span
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={col}
|
||||
className={className}
|
||||
style={width === undefined ? undefined : { width: `${width}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PreviewRows;
|
||||
@@ -1,5 +1,16 @@
|
||||
.select {
|
||||
width: 100%;
|
||||
flex: 0 1 220px;
|
||||
min-width: 120px;
|
||||
|
||||
:global(.ant-select-selector) {
|
||||
border-color: var(--l2-border) !important;
|
||||
background: var(--l2-background) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&:hover :global(.ant-select-selector) {
|
||||
border-color: var(--l3-border) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
@@ -53,3 +64,22 @@
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.createOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
background: transparent;
|
||||
color: var(--l2-foreground);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
// eslint-disable-next-line signoz/no-antd-components
|
||||
import { Select } from 'antd';
|
||||
|
||||
@@ -9,12 +10,14 @@ interface SectionPickerProps {
|
||||
options: SectionOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
function SectionPicker({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
onCreate,
|
||||
}: SectionPickerProps): JSX.Element {
|
||||
// `selectedLabel` (one line) shows in the trigger; `label` (two lines) in the list.
|
||||
const selectOptions = useMemo(
|
||||
@@ -32,7 +35,7 @@ function SectionPicker({
|
||||
label: (
|
||||
<span
|
||||
className={styles.optionRow}
|
||||
data-testid={`panel-section-option-${option.layoutIndex}`}
|
||||
data-testid={`panel-section-option-${option.value}`}
|
||||
>
|
||||
<option.Icon size={16} className={iconClass} />
|
||||
<span className={styles.optionText}>
|
||||
@@ -50,6 +53,8 @@ function SectionPicker({
|
||||
<Select<string>
|
||||
className={styles.select}
|
||||
popupClassName={styles.dropdown}
|
||||
placement="topLeft"
|
||||
popupMatchSelectWidth={false}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
data-testid="panel-section-select"
|
||||
@@ -58,6 +63,22 @@ function SectionPicker({
|
||||
trigger.parentElement ?? document.body
|
||||
}
|
||||
options={selectOptions}
|
||||
dropdownRender={(menu): JSX.Element => (
|
||||
<>
|
||||
{menu}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.createOption}
|
||||
// Keeps focus on the select so the click isn't lost to its blur.
|
||||
onMouseDown={(e): void => e.preventDefault()}
|
||||
onClick={onCreate}
|
||||
data-testid="panel-section-create"
|
||||
>
|
||||
<Plus size={14} />
|
||||
New section
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.nameInput {
|
||||
flex: 0 1 220px;
|
||||
min-width: 120px;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { Plus, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
|
||||
import SectionPicker from './SectionPicker';
|
||||
import type { SectionOption } from './types';
|
||||
|
||||
import styles from './SectionTarget.module.scss';
|
||||
|
||||
interface SectionTargetProps {
|
||||
options: SectionOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Name of the section to create; null when targeting an existing one. */
|
||||
newSectionTitle: string | null;
|
||||
onNewSectionTitleChange: (title: string | null) => void;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
function SectionTarget({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
newSectionTitle,
|
||||
onNewSectionTitleChange,
|
||||
onSubmit,
|
||||
}: SectionTargetProps): JSX.Element {
|
||||
const startCreating = (): void => onNewSectionTitleChange('');
|
||||
|
||||
if (newSectionTitle !== null) {
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
onSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
// Drop the draft only, not the drawer.
|
||||
e.stopPropagation();
|
||||
onNewSectionTitleChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
in
|
||||
<Input
|
||||
autoFocus
|
||||
value={newSectionTitle}
|
||||
placeholder="New section name"
|
||||
className={styles.nameInput}
|
||||
onChange={(e): void => onNewSectionTitleChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
testId="panel-section-name"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Cancel new section"
|
||||
onClick={(): void => onNewSectionTitleChange(null)}
|
||||
testId="panel-section-name-cancel"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (options.length > 1) {
|
||||
return (
|
||||
<>
|
||||
in
|
||||
<SectionPicker
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onCreate={startCreating}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<Plus />}
|
||||
onClick={startCreating}
|
||||
testId="panel-section-create"
|
||||
>
|
||||
Add to new section
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionTarget;
|
||||
@@ -0,0 +1,286 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import { useDashboardSections } from '../../../../hooks/useDashboardSections';
|
||||
import { usePanelPickerTargetStore } from '../../../../store/usePanelPickerTargetStore';
|
||||
import PanelTypeSelectionModal from '../PanelTypeSelectionModal';
|
||||
|
||||
// Stub the registry so the test doesn't pull in the real renderers and chart libs.
|
||||
jest.mock('../../../../Panels/registry', () => {
|
||||
const options = [
|
||||
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
|
||||
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
|
||||
{ kind: 'signoz/TablePanel', displayName: 'Table' },
|
||||
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
|
||||
{ kind: 'signoz/AreaChartPanel', displayName: 'Area' },
|
||||
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
|
||||
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
|
||||
{ kind: 'signoz/ListPanel', displayName: 'List' },
|
||||
{ kind: 'signoz/TextPanel', displayName: 'Text' },
|
||||
].map((option) => ({ ...option, icon: (): null => null }));
|
||||
return {
|
||||
PANEL_OPTIONS: options,
|
||||
getPanelDefinition: (kind: string): unknown =>
|
||||
options.find((option) => option.kind === kind),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../../../hooks/useDashboardSections', () => ({
|
||||
useDashboardSections: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUseDashboardSections = useDashboardSections as jest.Mock;
|
||||
|
||||
const ROOT_ONLY = [{ layoutIndex: 0, title: undefined, panelIds: [] }];
|
||||
const WITH_SECTIONS = [
|
||||
{ layoutIndex: 0, title: 'Overview', panelIds: [] },
|
||||
{ layoutIndex: 1, title: 'Latency', panelIds: [] },
|
||||
];
|
||||
|
||||
function renderDrawer(
|
||||
props: Partial<Parameters<typeof PanelTypeSelectionModal>[0]> = {},
|
||||
): { onSelect: jest.Mock; onClose: jest.Mock } {
|
||||
const onSelect = jest.fn();
|
||||
const onClose = jest.fn();
|
||||
render(
|
||||
<PanelTypeSelectionModal
|
||||
open
|
||||
onClose={onClose}
|
||||
onSelect={onSelect}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return { onSelect, onClose };
|
||||
}
|
||||
|
||||
describe('PanelTypeSelectionModal', () => {
|
||||
beforeEach(() => {
|
||||
mockUseDashboardSections.mockReturnValue(ROOT_ONLY);
|
||||
usePanelPickerTargetStore.getState().reset();
|
||||
});
|
||||
|
||||
it('adds the default Time Series panel when confirmed untouched', () => {
|
||||
const { onSelect } = renderDrawer();
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-type-confirm'));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('signoz/TimeSeriesPanel', {
|
||||
type: 'section',
|
||||
layoutIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('selects a tile, then adds it on confirm', () => {
|
||||
const { onSelect } = renderDrawer();
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-type-signoz/TablePanel'));
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('panel-type-signoz/TablePanel')).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-type-confirm'));
|
||||
expect(onSelect).toHaveBeenCalledWith('signoz/TablePanel', {
|
||||
type: 'section',
|
||||
layoutIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the section picker when the dashboard has a single layout', () => {
|
||||
renderDrawer();
|
||||
|
||||
expect(screen.queryByTestId('panel-section-select')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('targets the section it was opened against', () => {
|
||||
mockUseDashboardSections.mockReturnValue(WITH_SECTIONS);
|
||||
const { onSelect } = renderDrawer({ defaultLayoutIndex: 1 });
|
||||
|
||||
expect(screen.getByTestId('panel-section-select')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('panel-type-confirm'));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('signoz/TimeSeriesPanel', {
|
||||
type: 'section',
|
||||
layoutIndex: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to the root on a sectioned dashboard, even one without a root', () => {
|
||||
mockUseDashboardSections.mockReturnValue(WITH_SECTIONS);
|
||||
const { onSelect } = renderDrawer();
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-type-confirm'));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('signoz/TimeSeriesPanel', {
|
||||
type: 'root',
|
||||
});
|
||||
});
|
||||
|
||||
it('filters tiles by search and offers to clear an empty result', () => {
|
||||
renderDrawer();
|
||||
const search = screen.getByTestId('panel-type-search');
|
||||
|
||||
fireEvent.change(search, { target: { value: 'markdown' } });
|
||||
expect(screen.getByTestId('panel-type-signoz/TextPanel')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-type-signoz/TablePanel'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(search, { target: { value: 'zzz' } });
|
||||
expect(screen.getByText('No panel types match “zzz”')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Clear search'));
|
||||
expect(
|
||||
screen.getByTestId('panel-type-signoz/TablePanel'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('narrows tiles to the chosen category', () => {
|
||||
renderDrawer();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Raw records/ }));
|
||||
|
||||
expect(screen.getByTestId('panel-type-signoz/ListPanel')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-type-signoz/TimeSeriesPanel'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes on Cancel', () => {
|
||||
const { onClose, onSelect } = renderDrawer();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('new section', () => {
|
||||
it('asks for the named section, created when the panel is saved', () => {
|
||||
const { onSelect } = renderDrawer();
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-section-create'));
|
||||
const confirm = screen.getByTestId('panel-type-confirm');
|
||||
expect(confirm).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByTestId('panel-section-name'), {
|
||||
target: { value: ' Errors ' },
|
||||
});
|
||||
fireEvent.click(confirm);
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('signoz/TimeSeriesPanel', {
|
||||
type: 'newSection',
|
||||
title: 'Errors',
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes the draft for the dashboard preview', () => {
|
||||
const draft = (): unknown =>
|
||||
usePanelPickerTargetStore.getState().draftSection;
|
||||
renderDrawer();
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-section-create'));
|
||||
expect(draft()).toStrictEqual({
|
||||
title: '',
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId('panel-section-name'), {
|
||||
target: { value: 'Errors' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('panel-type-signoz/TablePanel'));
|
||||
expect(draft()).toStrictEqual({
|
||||
title: 'Errors',
|
||||
panelKind: 'signoz/TablePanel',
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-section-name-cancel'));
|
||||
expect(draft()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns to the section picker when the draft is cancelled', () => {
|
||||
mockUseDashboardSections.mockReturnValue(WITH_SECTIONS);
|
||||
renderDrawer();
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole('combobox'));
|
||||
fireEvent.click(screen.getByTestId('panel-section-create'));
|
||||
expect(screen.getByTestId('panel-section-name')).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId('panel-section-name'), {
|
||||
key: 'Escape',
|
||||
});
|
||||
expect(screen.queryByTestId('panel-section-name')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('panel-section-select')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('section highlight', () => {
|
||||
const target = (): unknown => usePanelPickerTargetStore.getState().target;
|
||||
|
||||
it('targets the only section without outlining it', () => {
|
||||
renderDrawer();
|
||||
|
||||
expect(target()).toStrictEqual({
|
||||
layoutIndex: 0,
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
outline: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('publishes the chosen section and kind while open', () => {
|
||||
mockUseDashboardSections.mockReturnValue(WITH_SECTIONS);
|
||||
renderDrawer({ defaultLayoutIndex: 1 });
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-type-signoz/TablePanel'));
|
||||
|
||||
expect(target()).toStrictEqual({
|
||||
layoutIndex: 1,
|
||||
panelKind: 'signoz/TablePanel',
|
||||
outline: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('drops the target while a new section is being named', () => {
|
||||
renderDrawer();
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-section-create'));
|
||||
|
||||
expect(target()).toBeNull();
|
||||
});
|
||||
|
||||
it('restores the pre-reveal scroll position on cancel', () => {
|
||||
mockUseDashboardSections.mockReturnValue(WITH_SECTIONS);
|
||||
renderDrawer({ defaultLayoutIndex: 1 });
|
||||
const scrollTo = jest.fn();
|
||||
// jsdom elements have no scrollTo.
|
||||
const scroller = { scrollTo } as unknown as HTMLElement;
|
||||
usePanelPickerTargetStore
|
||||
.getState()
|
||||
.rememberScrollOrigin({ element: scroller, top: 120 });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 120,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
expect(target()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the scroll position when a panel is added', () => {
|
||||
mockUseDashboardSections.mockReturnValue(WITH_SECTIONS);
|
||||
renderDrawer({ defaultLayoutIndex: 1 });
|
||||
const scrollTo = jest.fn();
|
||||
// jsdom elements have no scrollTo.
|
||||
const scroller = { scrollTo } as unknown as HTMLElement;
|
||||
usePanelPickerTargetStore
|
||||
.getState()
|
||||
.rememberScrollOrigin({ element: scroller, top: 120 });
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-type-confirm'));
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
expect(usePanelPickerTargetStore.getState().scrollOrigin).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { PANEL_OPTIONS, type PanelOption } from '../../../Panels/registry';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
|
||||
export type PanelTypeGroupId =
|
||||
| 'trends'
|
||||
| 'compare'
|
||||
| 'distributions'
|
||||
| 'single'
|
||||
| 'raw'
|
||||
| 'docs';
|
||||
|
||||
export const PANEL_TYPE_GROUPS: { id: PanelTypeGroupId; label: string }[] = [
|
||||
{ id: 'trends', label: 'Trends over time' },
|
||||
{ id: 'compare', label: 'Compare & rank' },
|
||||
{ id: 'distributions', label: 'Distributions' },
|
||||
{ id: 'single', label: 'Single values' },
|
||||
{ id: 'raw', label: 'Raw records' },
|
||||
{ id: 'docs', label: 'Documentation' },
|
||||
];
|
||||
|
||||
interface PanelTypeMeta {
|
||||
group: PanelTypeGroupId;
|
||||
description: string;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
// Total over PanelKind, so a new kind fails to compile until it's placed in a group.
|
||||
const PANEL_TYPE_META: Record<PanelKind, PanelTypeMeta> = {
|
||||
'signoz/TimeSeriesPanel': {
|
||||
group: 'trends',
|
||||
description: 'Values plotted against time',
|
||||
},
|
||||
'signoz/AreaChartPanel': {
|
||||
group: 'trends',
|
||||
description: 'Stacked volume over time',
|
||||
isNew: true,
|
||||
},
|
||||
'signoz/BarChartPanel': {
|
||||
group: 'compare',
|
||||
description: 'Compare values across categories',
|
||||
},
|
||||
'signoz/PieChartPanel': { group: 'compare', description: 'Share of a whole' },
|
||||
'signoz/HistogramPanel': {
|
||||
group: 'distributions',
|
||||
description: 'Distribution of values into buckets',
|
||||
},
|
||||
'signoz/NumberPanel': {
|
||||
group: 'single',
|
||||
description: 'One aggregate value, large',
|
||||
},
|
||||
'signoz/TablePanel': {
|
||||
group: 'raw',
|
||||
description: 'Rows and columns of results',
|
||||
},
|
||||
'signoz/ListPanel': { group: 'raw', description: 'Raw log and span records' },
|
||||
'signoz/TextPanel': {
|
||||
group: 'docs',
|
||||
description: 'Markdown notes and context',
|
||||
isNew: true,
|
||||
},
|
||||
};
|
||||
|
||||
export type PanelTypeItem = PanelOption & Omit<PanelTypeMeta, 'group'>;
|
||||
|
||||
export interface PanelTypeGroup {
|
||||
id: PanelTypeGroupId;
|
||||
label: string;
|
||||
items: PanelTypeItem[];
|
||||
}
|
||||
|
||||
/** Every group with the items matching `query` (name, description or group label); empty groups included. */
|
||||
export function filterPanelTypeGroups(query: string): PanelTypeGroup[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
return PANEL_TYPE_GROUPS.map(({ id, label }) => ({
|
||||
id,
|
||||
label,
|
||||
items: PANEL_OPTIONS.filter(({ kind }) => PANEL_TYPE_META[kind].group === id)
|
||||
.map((option) => ({ ...option, ...PANEL_TYPE_META[option.kind] }))
|
||||
.filter(
|
||||
(item) =>
|
||||
!q ||
|
||||
item.displayName.toLowerCase().includes(q) ||
|
||||
item.description.toLowerCase().includes(q) ||
|
||||
label.toLowerCase().includes(q),
|
||||
),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import PreviewBars from './PreviewBars';
|
||||
import PreviewRows, { type PreviewCell } from './PreviewRows';
|
||||
|
||||
import styles from './PanelTypePreview.module.scss';
|
||||
|
||||
// CSS var() doesn't resolve in SVG presentation attributes, so colors go via `style`.
|
||||
const LINE = { stroke: 'var(--bg-robin-400)' };
|
||||
const AREA_FILL = { fill: 'var(--bg-robin-500)', fillOpacity: 0.22 };
|
||||
const RING_TRACK = { stroke: 'var(--l3-background)' };
|
||||
const RING_PRIMARY = { stroke: 'var(--bg-robin-500)' };
|
||||
const RING_SECONDARY = { stroke: 'var(--bg-robin-400)', strokeOpacity: 0.55 };
|
||||
|
||||
const LINE_PATH =
|
||||
'M0 34 12 26 24 30 36 16 48 22 60 10 72 18 84 8 96 14 108 5 120 11';
|
||||
const AREA_PATH = 'M0 30 20 20 40 26 60 12 80 18 100 8 120 14';
|
||||
|
||||
const HEAD: PreviewCell = { className: styles.headCell };
|
||||
const CELL: PreviewCell = { className: styles.cell };
|
||||
const ACCENT: PreviewCell = { className: styles.accentCell };
|
||||
const DOT: PreviewCell = { className: styles.dot };
|
||||
const ACCENT_DOT: PreviewCell = { className: styles.accentDot };
|
||||
|
||||
const TABLE_ROWS = [
|
||||
[HEAD, HEAD, HEAD],
|
||||
[CELL, CELL, ACCENT],
|
||||
[CELL, CELL, CELL],
|
||||
[CELL, CELL, CELL],
|
||||
];
|
||||
const LIST_ROWS = [
|
||||
[ACCENT_DOT, CELL],
|
||||
[DOT, CELL],
|
||||
[DOT, CELL],
|
||||
[DOT, CELL],
|
||||
];
|
||||
const TEXT_ROWS = [45, 100, 92, 64].map((width, i) => [
|
||||
{ ...(i === 0 ? HEAD : CELL), width },
|
||||
]);
|
||||
|
||||
/** Decorative mini-chart per panel kind; total so a new kind needs a sketch. */
|
||||
export const PANEL_TYPE_PREVIEWS: Record<PanelKind, JSX.Element> = {
|
||||
'signoz/TimeSeriesPanel': (
|
||||
<svg viewBox="0 0 120 44" preserveAspectRatio="none" className={styles.svg}>
|
||||
<path
|
||||
d={LINE_PATH}
|
||||
fill="none"
|
||||
strokeWidth={1.6}
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={LINE}
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
'signoz/AreaChartPanel': (
|
||||
<svg viewBox="0 0 120 44" preserveAspectRatio="none" className={styles.svg}>
|
||||
<path d={`${AREA_PATH}V44H0Z`} style={AREA_FILL} />
|
||||
<path
|
||||
d={AREA_PATH}
|
||||
fill="none"
|
||||
strokeWidth={1.6}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
style={LINE}
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
'signoz/BarChartPanel': (
|
||||
<PreviewBars heights={[80, 58, 44, 30, 18]} fade className={styles.bars} />
|
||||
),
|
||||
'signoz/HistogramPanel': (
|
||||
<PreviewBars
|
||||
heights={[14, 26, 52, 88, 100, 70, 40, 20, 10]}
|
||||
fade={false}
|
||||
className={styles.histogram}
|
||||
/>
|
||||
),
|
||||
'signoz/PieChartPanel': (
|
||||
<svg viewBox="0 0 44 44" className={styles.svg}>
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
r="16"
|
||||
fill="none"
|
||||
strokeWidth={8}
|
||||
style={RING_TRACK}
|
||||
/>
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
r="16"
|
||||
fill="none"
|
||||
strokeWidth={8}
|
||||
strokeDasharray="50 100.5"
|
||||
transform="rotate(-90 22 22)"
|
||||
style={RING_PRIMARY}
|
||||
/>
|
||||
<circle
|
||||
cx="22"
|
||||
cy="22"
|
||||
r="16"
|
||||
fill="none"
|
||||
strokeWidth={8}
|
||||
strokeDasharray="28 100.5"
|
||||
strokeDashoffset={-50}
|
||||
transform="rotate(-90 22 22)"
|
||||
style={RING_SECONDARY}
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
'signoz/NumberPanel': (
|
||||
<div className={styles.number}>
|
||||
<span className={styles.numberValue}>
|
||||
99.7<span className={styles.numberUnit}>%</span>
|
||||
</span>
|
||||
<span className={styles.numberLabel}>Availability</span>
|
||||
</div>
|
||||
),
|
||||
'signoz/TablePanel': (
|
||||
<PreviewRows rows={TABLE_ROWS} rowClassName={styles.tableRow} />
|
||||
),
|
||||
'signoz/ListPanel': (
|
||||
<PreviewRows rows={LIST_ROWS} rowClassName={styles.listRow} />
|
||||
),
|
||||
'signoz/TextPanel': <PreviewRows rows={TEXT_ROWS} />,
|
||||
};
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { IconSize } from '@signozhq/icons';
|
||||
import type { ComponentType, SVGProps } from 'react';
|
||||
|
||||
import type { NewPanelTarget } from '../../../patchOps';
|
||||
|
||||
type IconProps = Omit<SVGProps<SVGSVGElement>, 'ref'> & {
|
||||
size?: number | IconSize;
|
||||
strokeWidth?: number;
|
||||
};
|
||||
|
||||
export interface SectionOption {
|
||||
/** The section's `layoutIndex`, stringified for the Select value. */
|
||||
/** `layoutIndex` stringified, or "root" for a root yet to be created. */
|
||||
value: string;
|
||||
layoutIndex: number;
|
||||
target: NewPanelTarget;
|
||||
/** Section title, or "Dashboard (root)" for the untitled top-level layout. */
|
||||
label: string;
|
||||
/** Caption under the label. */
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { usePanelPickerTargetStore } from '../../../store/usePanelPickerTargetStore';
|
||||
|
||||
export function usePanelPickerDraftSection(
|
||||
title: string | null,
|
||||
panelKind: PanelKind,
|
||||
enabled: boolean,
|
||||
): void {
|
||||
const setDraftSection = usePanelPickerTargetStore((s) => s.setDraftSection);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
setDraftSection(title === null ? null : { title, panelKind });
|
||||
}
|
||||
}, [enabled, title, panelKind, setDraftSection]);
|
||||
|
||||
// Cleared only on close, so typing updates the preview without unmounting it.
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return undefined;
|
||||
}
|
||||
return (): void => setDraftSection(null);
|
||||
}, [enabled, setDraftSection]);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { usePanelPickerTargetStore } from '../../../store/usePanelPickerTargetStore';
|
||||
|
||||
interface UsePanelPickerTargetArgs {
|
||||
open: boolean;
|
||||
layoutIndex: number | undefined;
|
||||
panelKind: PanelKind;
|
||||
outline: boolean;
|
||||
}
|
||||
|
||||
/** Publishes where the open picker will add its panel, for the dashboard behind the drawer. */
|
||||
export function usePanelPickerTarget({
|
||||
open,
|
||||
layoutIndex,
|
||||
panelKind,
|
||||
outline,
|
||||
}: UsePanelPickerTargetArgs): void {
|
||||
const setTarget = usePanelPickerTargetStore((s) => s.setTarget);
|
||||
|
||||
// Only the open picker writes, so the closed instances mounted per section don't clobber it.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTarget(
|
||||
layoutIndex === undefined ? null : { layoutIndex, panelKind, outline },
|
||||
);
|
||||
}
|
||||
}, [open, layoutIndex, panelKind, outline, setTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
return (): void => setTarget(null);
|
||||
}, [open, setTarget]);
|
||||
}
|
||||
@@ -7,33 +7,47 @@ const ROOT_LABEL = 'Dashboard (root)';
|
||||
const ROOT_DESCRIPTION = 'Top level — no section';
|
||||
const SECTION_DESCRIPTION = 'Section';
|
||||
|
||||
/** Maps dashboard sections to section-picker options (untitled → "root"). */
|
||||
const NEW_ROOT_VALUE = 'root';
|
||||
|
||||
/** Maps dashboard sections to section-picker options; a sectioned dashboard always offers the root. */
|
||||
export function buildSectionOptions(
|
||||
sections: DashboardSection[],
|
||||
): SectionOption[] {
|
||||
const rootSection = findRootSection(sections);
|
||||
return sections.map((section) => {
|
||||
const options: SectionOption[] = sections.map((section) => {
|
||||
const isRoot = rootSection === section;
|
||||
return {
|
||||
value: String(section.layoutIndex),
|
||||
layoutIndex: section.layoutIndex,
|
||||
target: { type: 'section', layoutIndex: section.layoutIndex },
|
||||
label: isRoot ? ROOT_LABEL : (section.title as string),
|
||||
description: isRoot ? ROOT_DESCRIPTION : SECTION_DESCRIPTION,
|
||||
isRoot,
|
||||
Icon: isRoot ? LayoutDashboard : Rows2,
|
||||
};
|
||||
});
|
||||
if (!rootSection && sections.some((section) => section.title)) {
|
||||
options.unshift({
|
||||
value: NEW_ROOT_VALUE,
|
||||
target: { type: 'root' },
|
||||
label: ROOT_LABEL,
|
||||
description: ROOT_DESCRIPTION,
|
||||
isRoot: true,
|
||||
Icon: LayoutDashboard,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the option the picker should open on: the section the "Add panel" was
|
||||
* triggered from when present and still valid, otherwise the first option.
|
||||
* triggered from when present and still valid, otherwise the dashboard root.
|
||||
*/
|
||||
export function resolveDefaultSectionValue(
|
||||
options: SectionOption[],
|
||||
defaultLayoutIndex: number | undefined,
|
||||
): string {
|
||||
const fallback = options[0]?.value ?? '';
|
||||
const fallback =
|
||||
(options.find((option) => option.isRoot) ?? options[0])?.value ?? '';
|
||||
if (defaultLayoutIndex === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.draftSection {
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
outline: 1px dashed var(--bg-robin-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: var(--l3-foreground);
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ChevronDown } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { NEW_PANEL_SIZE } from '../../../patchOps';
|
||||
import { usePanelPickerTargetStore } from '../../../store/usePanelPickerTargetStore';
|
||||
import {
|
||||
GRID_MARGIN,
|
||||
gridItemHeight,
|
||||
gridItemWidth,
|
||||
} from '../SectionGrid/gridMetrics';
|
||||
import NewPanelPlaceholder from '../SectionGrid/NewPanelPlaceholder';
|
||||
|
||||
import styles from './DraftSection.module.scss';
|
||||
|
||||
function DraftSection(): JSX.Element | null {
|
||||
const draft = usePanelPickerTargetStore((s) => s.draftSection);
|
||||
|
||||
if (draft === null) {
|
||||
return null;
|
||||
}
|
||||
const title = draft.title.trim();
|
||||
|
||||
return (
|
||||
<div className={styles.draftSection} data-testid="draft-section">
|
||||
<div className={styles.header}>
|
||||
<ChevronDown size={14} />
|
||||
<Typography.Text className={title ? styles.title : styles.placeholder}>
|
||||
{title || 'New section'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div style={{ padding: GRID_MARGIN }}>
|
||||
<div
|
||||
style={{
|
||||
width: gridItemWidth(NEW_PANEL_SIZE.width),
|
||||
height: gridItemHeight(NEW_PANEL_SIZE.height),
|
||||
}}
|
||||
>
|
||||
<NewPanelPlaceholder kind={draft.panelKind} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DraftSection;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
|
||||
import { usePanelPickerTargetStore } from '../../../../store/usePanelPickerTargetStore';
|
||||
import DraftSection from '../DraftSection';
|
||||
|
||||
jest.mock('../../../../Panels/registry', () => ({
|
||||
getPanelDefinition: (): { displayName: string } => ({ displayName: 'Table' }),
|
||||
}));
|
||||
|
||||
describe('DraftSection', () => {
|
||||
const scrollTo = jest.fn();
|
||||
const setDraft = (title: string): void =>
|
||||
usePanelPickerTargetStore
|
||||
.getState()
|
||||
.setDraftSection({ title, panelKind: 'signoz/TablePanel' });
|
||||
|
||||
beforeEach(() => {
|
||||
usePanelPickerTargetStore.getState().reset();
|
||||
scrollTo.mockClear();
|
||||
// jsdom elements have no scrollTo.
|
||||
Object.defineProperty(document.documentElement, 'scrollTo', {
|
||||
value: scrollTo,
|
||||
configurable: true,
|
||||
});
|
||||
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders nothing while no section is being created', () => {
|
||||
render(<DraftSection />);
|
||||
|
||||
expect(screen.queryByTestId('draft-section')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('previews the typed name with the new panel, scrolling to it once', () => {
|
||||
render(<DraftSection />);
|
||||
|
||||
act(() => setDraft(''));
|
||||
expect(screen.getByText('New section')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('new-panel-placeholder')).toHaveTextContent(
|
||||
'Table',
|
||||
);
|
||||
|
||||
act(() => setDraft('Errors'));
|
||||
expect(screen.getByText('Errors')).toBeInTheDocument();
|
||||
expect(scrollTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,12 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pickerTarget {
|
||||
border-radius: 4px;
|
||||
outline: 1px dashed var(--bg-robin-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.dragging {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user