mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-02 23:20:34 +01:00
Compare commits
30 Commits
e2e/dashbo
...
feat/v2-da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01a4762038 | ||
|
|
5fd20b98f8 | ||
|
|
395838bef9 | ||
|
|
1a680e18fe | ||
|
|
197d5387c5 | ||
|
|
9d288ee9dc | ||
|
|
3e5c5a188c | ||
|
|
9b475fd439 | ||
|
|
79042e4ad5 | ||
|
|
aebbd64c30 | ||
|
|
e4b31ec070 | ||
|
|
6a9786e24a | ||
|
|
a487b311bc | ||
|
|
6473066193 | ||
|
|
fb0d34ae35 | ||
|
|
ba684acba3 | ||
|
|
184724003a | ||
|
|
a4d3f10da8 | ||
|
|
a71ac2ada6 | ||
|
|
0963ff08cd | ||
|
|
e43aeb8e24 | ||
|
|
9074208b09 | ||
|
|
571e23910e | ||
|
|
5e94f7ac6e | ||
|
|
387ad06c2d | ||
|
|
72ff433c20 | ||
|
|
587f518599 | ||
|
|
bfc50ee9c3 | ||
|
|
4b08ba1330 | ||
|
|
557a7120df |
19
.github/CODEOWNERS
vendored
19
.github/CODEOWNERS
vendored
@@ -169,3 +169,22 @@ go.mod @therealpandey
|
||||
## Dashboard V2
|
||||
/frontend/src/pages/DashboardPageV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/DashboardsListPageV2/ @SigNoz/pulse-frontend
|
||||
|
||||
## Infrastructure Monitoring
|
||||
/frontend/src/pages/InfrastructureMonitoring/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/InfraMonitoringHosts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/InfraMonitoringK8s/ @SigNoz/pulse-frontend
|
||||
|
||||
## Alerts
|
||||
/frontend/src/pages/AlertList/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/AlertDetails/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/CreateAlert/ @SigNoz/pulse-frontend
|
||||
/frontend/src/pages/EditRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AlertHistory/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertRule/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/CreateAlertV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/EditAlertV2/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/FormAlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/ListAlertRules/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
|
||||
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
|
||||
|
||||
@@ -64,16 +64,16 @@ web:
|
||||
settings:
|
||||
posthog:
|
||||
# Whether to enable PostHog in web.
|
||||
enabled: true
|
||||
enabled: false
|
||||
appcues:
|
||||
# Whether to enable Appcues in web.
|
||||
enabled: true
|
||||
enabled: false
|
||||
sentry:
|
||||
# Whether to enable Sentry in web.
|
||||
enabled: true
|
||||
enabled: false
|
||||
pylon:
|
||||
# Whether to enable Pylon in web.
|
||||
enabled: true
|
||||
enabled: false
|
||||
|
||||
##################### Cache #####################
|
||||
cache:
|
||||
|
||||
@@ -6525,6 +6525,15 @@ components:
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
SpantypesGettableTraceAggregations:
|
||||
properties:
|
||||
aggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanAggregationResult'
|
||||
type: array
|
||||
required:
|
||||
- aggregations
|
||||
type: object
|
||||
SpantypesGettableWaterfallTrace:
|
||||
properties:
|
||||
aggregations:
|
||||
@@ -6563,6 +6572,15 @@ components:
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
SpantypesOtelSpanRef:
|
||||
properties:
|
||||
refType:
|
||||
type: string
|
||||
spanId:
|
||||
type: string
|
||||
traceId:
|
||||
type: string
|
||||
type: object
|
||||
SpantypesPostableSpanMapper:
|
||||
properties:
|
||||
config:
|
||||
@@ -6590,6 +6608,15 @@ components:
|
||||
- name
|
||||
- condition
|
||||
type: object
|
||||
SpantypesPostableTraceAggregations:
|
||||
properties:
|
||||
aggregations:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesSpanAggregation'
|
||||
type: array
|
||||
required:
|
||||
- aggregations
|
||||
type: object
|
||||
SpantypesPostableWaterfall:
|
||||
properties:
|
||||
aggregations:
|
||||
@@ -6614,6 +6641,9 @@ components:
|
||||
$ref: '#/components/schemas/SpantypesSpanAggregationType'
|
||||
field:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
required:
|
||||
- field
|
||||
- aggregation
|
||||
type: object
|
||||
SpantypesSpanAggregationResult:
|
||||
properties:
|
||||
@@ -6627,6 +6657,10 @@ components:
|
||||
type: integer
|
||||
nullable: true
|
||||
type: object
|
||||
required:
|
||||
- field
|
||||
- aggregation
|
||||
- value
|
||||
type: object
|
||||
SpantypesSpanAggregationType:
|
||||
enum:
|
||||
@@ -6810,6 +6844,10 @@ components:
|
||||
type: string
|
||||
parent_span_id:
|
||||
type: string
|
||||
references:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesOtelSpanRef'
|
||||
type: array
|
||||
resource:
|
||||
additionalProperties:
|
||||
type: string
|
||||
@@ -6835,6 +6873,8 @@ components:
|
||||
type: string
|
||||
trace_state:
|
||||
type: string
|
||||
required:
|
||||
- references
|
||||
type: object
|
||||
TagtypesPostableTag:
|
||||
properties:
|
||||
@@ -12265,6 +12305,75 @@ paths:
|
||||
summary: Test notification channel (deprecated)
|
||||
tags:
|
||||
- channels
|
||||
/api/v1/traces/{traceID}/aggregations:
|
||||
post:
|
||||
deprecated: false
|
||||
description: Computes span aggregations grouped by requested field.
|
||||
operationId: GetTraceAggregations
|
||||
parameters:
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpantypesPostableTraceAggregations'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableTraceAggregations'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get aggregations for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v1/user:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -7753,12 +7753,34 @@ export type SpantypesSpanAggregationResultDTOValue =
|
||||
SpantypesSpanAggregationResultDTOValueAnyOf | null;
|
||||
|
||||
export interface SpantypesSpanAggregationResultDTO {
|
||||
aggregation?: SpantypesSpanAggregationTypeDTO;
|
||||
field?: TelemetrytypesTelemetryFieldKeyDTO;
|
||||
aggregation: SpantypesSpanAggregationTypeDTO;
|
||||
field: TelemetrytypesTelemetryFieldKeyDTO;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
value?: SpantypesSpanAggregationResultDTOValue;
|
||||
value: SpantypesSpanAggregationResultDTOValue;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableTraceAggregationsDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
aggregations: SpantypesSpanAggregationResultDTO[];
|
||||
}
|
||||
|
||||
export interface SpantypesOtelSpanRefDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
refType?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
spanId?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
|
||||
@@ -7855,6 +7877,10 @@ export interface SpantypesWaterfallSpanDTO {
|
||||
* @type string
|
||||
*/
|
||||
parent_span_id?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
references: SpantypesOtelSpanRefDTO[];
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
@@ -8000,8 +8026,15 @@ export interface SpantypesPostableSpanMapperGroupDTO {
|
||||
}
|
||||
|
||||
export interface SpantypesSpanAggregationDTO {
|
||||
aggregation?: SpantypesSpanAggregationTypeDTO;
|
||||
field?: TelemetrytypesTelemetryFieldKeyDTO;
|
||||
aggregation: SpantypesSpanAggregationTypeDTO;
|
||||
field: TelemetrytypesTelemetryFieldKeyDTO;
|
||||
}
|
||||
|
||||
export interface SpantypesPostableTraceAggregationsDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
aggregations: SpantypesSpanAggregationDTO[];
|
||||
}
|
||||
|
||||
export interface SpantypesPostableWaterfallDTO {
|
||||
@@ -9344,6 +9377,17 @@ export type UpdateSpanMapperPathParameters = {
|
||||
groupId: string;
|
||||
mapperId: string;
|
||||
};
|
||||
export type GetTraceAggregationsPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetTraceAggregations200 = {
|
||||
data: SpantypesGettableTraceAggregationsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUsersDeprecated200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -12,17 +12,120 @@ import type {
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
GetTraceAggregations200,
|
||||
GetTraceAggregationsPathParameters,
|
||||
GetWaterfall200,
|
||||
GetWaterfallPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableTraceAggregationsDTO,
|
||||
SpantypesPostableWaterfallDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Computes span aggregations grouped by requested field.
|
||||
* @summary Get aggregations for a trace
|
||||
*/
|
||||
export const getTraceAggregations = (
|
||||
{ traceID }: GetTraceAggregationsPathParameters,
|
||||
spantypesPostableTraceAggregationsDTO?: BodyType<SpantypesPostableTraceAggregationsDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetTraceAggregations200>({
|
||||
url: `/api/v1/traces/${traceID}/aggregations`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableTraceAggregationsDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetTraceAggregationsMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getTraceAggregations>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetTraceAggregationsPathParameters;
|
||||
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getTraceAggregations>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetTraceAggregationsPathParameters;
|
||||
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['getTraceAggregations'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof getTraceAggregations>>,
|
||||
{
|
||||
pathParams: GetTraceAggregationsPathParameters;
|
||||
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return getTraceAggregations(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type GetTraceAggregationsMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getTraceAggregations>>
|
||||
>;
|
||||
export type GetTraceAggregationsMutationBody =
|
||||
| BodyType<SpantypesPostableTraceAggregationsDTO>
|
||||
| undefined;
|
||||
export type GetTraceAggregationsMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get aggregations for a trace
|
||||
*/
|
||||
export const useGetTraceAggregations = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getTraceAggregations>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetTraceAggregationsPathParameters;
|
||||
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof getTraceAggregations>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetTraceAggregationsPathParameters;
|
||||
data?: BodyType<SpantypesPostableTraceAggregationsDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getGetTraceAggregationsMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination
|
||||
* @summary Get waterfall view for a trace
|
||||
|
||||
@@ -120,7 +120,8 @@ export const interceptorRejected = async (
|
||||
!(
|
||||
response.config.url === '/sessions' && response.config.method === 'delete'
|
||||
) &&
|
||||
response.config.url !== '/authz/check'
|
||||
response.config.url !== '/authz/check' &&
|
||||
response.config.url !== '/api/v2/reset_password_tokens/verify'
|
||||
) {
|
||||
try {
|
||||
const accessToken = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#prefix__clip0_4062_7291)" stroke-width="1.167" stroke-linecap="round" stroke-linejoin="round"><path d="M7 12.833A5.833 5.833 0 107 1.167a5.833 5.833 0 000 11.666z" fill="#E5484D" stroke="#E5484D"/><path d="M8.75 5.25l-3.5 3.5M5.25 5.25l3.5 3.5" stroke="#121317"/></g><defs><clipPath id="prefix__clip0_4062_7291"><path fill="#fff" d="M0 0h14v14H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 467 B |
@@ -4,6 +4,7 @@ import { Dot } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
import { Popover } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { AIAssistantEvents } from 'container/AIAssistant/events';
|
||||
@@ -109,7 +110,7 @@ function HeaderRightSection({
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<TooltipSimple title="Noz">
|
||||
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
|
||||
@@ -69,6 +69,8 @@ export function useLogsTableColumns({
|
||||
id: 'timestamp',
|
||||
header: 'Timestamp',
|
||||
accessorFn: (log): unknown => log.timestamp,
|
||||
canBeHidden: false,
|
||||
enableRemove: false,
|
||||
width: { default: 170, min: 170 },
|
||||
cell: ({ value }): ReactElement => {
|
||||
const ts = value as string | number;
|
||||
@@ -92,6 +94,7 @@ export function useLogsTableColumns({
|
||||
header: 'Body',
|
||||
accessorFn: (log): string => getBodyDisplayString(log.body),
|
||||
canBeHidden: false,
|
||||
enableRemove: false,
|
||||
width: { default: '100%', min: 300 },
|
||||
cell: ({ value, isActive }): ReactElement => (
|
||||
<TanStackTable.Text
|
||||
|
||||
@@ -62,6 +62,7 @@ describe('LogsFormatOptionsMenu (unit)', () => {
|
||||
onSearch: jest.fn(),
|
||||
onSelect: jest.fn(),
|
||||
onRemove: jest.fn(),
|
||||
onReorder: jest.fn(),
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
|
||||
2
frontend/src/components/Noz/Noz.constants.ts
Normal file
2
frontend/src/components/Noz/Noz.constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Shared hover copy for every Noz entry point (header, floating trigger, sidebar). */
|
||||
export const NOZ_TOOLTIP_TITLE = 'Noz, your AI teammate';
|
||||
@@ -322,9 +322,7 @@ function TanStackTableInner<TData>(
|
||||
});
|
||||
|
||||
const hasSingleColumn = useMemo(
|
||||
() =>
|
||||
effectiveColumns.filter((c) => !c.pin && c.enableRemove !== false).length <=
|
||||
1,
|
||||
() => effectiveColumns.filter((c) => !c.pin).length <= 1,
|
||||
[effectiveColumns],
|
||||
);
|
||||
|
||||
|
||||
@@ -42,4 +42,5 @@ export enum LOCALSTORAGE {
|
||||
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
|
||||
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ROUTES from 'constants/routes';
|
||||
import Noz from 'components/Noz/Noz';
|
||||
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
|
||||
|
||||
import { AIAssistantEvents, AIAssistantOpenSource } from '../events';
|
||||
import { normalizePage } from '../hooks/useAIAssistantAnalyticsContext';
|
||||
@@ -42,16 +43,15 @@ export default function AIAssistantTrigger(): JSX.Element | null {
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipSimple title="Noz">
|
||||
<TooltipSimple title={NOZ_TOOLTIP_TITLE}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className={`${styles.trigger} noz-wave`}
|
||||
onClick={handleOpen}
|
||||
aria-label="Open Noz"
|
||||
>
|
||||
<Noz size={24} />
|
||||
</Button>
|
||||
prefix={<Noz size={24} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -196,7 +196,11 @@ function Footer(): JSX.Element {
|
||||
</Button>
|
||||
);
|
||||
if (alertValidationMessage) {
|
||||
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
|
||||
button = (
|
||||
<Tooltip title={alertValidationMessage}>
|
||||
<span>{button}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return button;
|
||||
}, [
|
||||
@@ -224,7 +228,11 @@ function Footer(): JSX.Element {
|
||||
</Button>
|
||||
);
|
||||
if (alertValidationMessage) {
|
||||
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
|
||||
button = (
|
||||
<Tooltip title={alertValidationMessage}>
|
||||
<span>{button}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return button;
|
||||
}, [
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
padding: 12px 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
.settings-container-root {
|
||||
.ant-drawer-wrapper-body {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.ant-drawer-header {
|
||||
height: 48px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
padding: 14px 14px 14px 11px;
|
||||
|
||||
.ant-drawer-header-title {
|
||||
gap: 16px;
|
||||
|
||||
.ant-drawer-title {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.ant-drawer-close {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-inline-end: 0px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-drawer-body {
|
||||
padding: 16px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { memo, PropsWithChildren, ReactElement } from 'react';
|
||||
import { Drawer } from 'antd';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
|
||||
import './SettingsDrawer.styles.scss';
|
||||
|
||||
type SettingsDrawerProps = PropsWithChildren<{
|
||||
drawerTitle: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}>;
|
||||
|
||||
function SettingsDrawer({
|
||||
children,
|
||||
drawerTitle,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: SettingsDrawerProps): JSX.Element {
|
||||
return (
|
||||
<Drawer
|
||||
title={drawerTitle}
|
||||
placement="right"
|
||||
width="50%"
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
rootClassName="settings-container-root"
|
||||
>
|
||||
{/* Need to type cast because of OverlayScrollbar type definition. We should be good once we remove it. */}
|
||||
<OverlayScrollbar>{children as ReactElement}</OverlayScrollbar>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SettingsDrawer);
|
||||
@@ -0,0 +1,395 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
Check,
|
||||
ClipboardCopy,
|
||||
Ellipsis,
|
||||
FileJson,
|
||||
Fullscreen,
|
||||
Globe,
|
||||
LockKeyhole,
|
||||
PenLine,
|
||||
Plus,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Button, Card, Input, Modal, Popover, Tag, Tooltip } from 'antd';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
lockDashboardV2,
|
||||
patchDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
|
||||
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { Base64Icons } from '../../DashboardContainer/DashboardSettings/General/utils';
|
||||
import DashboardSettingsV2 from '../DashboardSettings';
|
||||
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
|
||||
import SettingsDrawer from './SettingsDrawer';
|
||||
|
||||
import '../../DashboardContainer/DashboardDescription/Description.styles.scss';
|
||||
|
||||
import type { V2Dashboard } from '../utils';
|
||||
|
||||
interface DashboardDescriptionV2Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
handle: FullScreenHandle;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function DashboardDescriptionV2(props: DashboardDescriptionV2Props): JSX.Element {
|
||||
const { dashboard, handle, onRefetch } = props;
|
||||
|
||||
const id = dashboard?.id ?? '';
|
||||
const isDashboardLocked = !!dashboard?.locked;
|
||||
|
||||
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const title = dashboard?.spec?.display?.name ?? '';
|
||||
const description = dashboard?.spec?.display?.description ?? '';
|
||||
const image = dashboard?.image || Base64Icons[0];
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
(dashboard?.tags ?? []).map((t) =>
|
||||
t.key === t.value ? t.key : `${t.key}:${t.value}`,
|
||||
),
|
||||
[dashboard?.tags],
|
||||
);
|
||||
|
||||
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
|
||||
const [isDashboardSettingsOpen, setIsDashbordSettingsOpen] =
|
||||
useState<boolean>(false);
|
||||
const [isRenameDashboardOpen, setIsRenameDashboardOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard?.createdBy && dashboard.createdBy === user.email;
|
||||
const addPanelPermission = !isDashboardLocked;
|
||||
// V2 public dashboard wiring lives separately; treat as not-public for chrome.
|
||||
const isPublicDashboard = false;
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { t } = useTranslation(['dashboard', 'common']);
|
||||
|
||||
const [isRenameLoading, setIsRenameLoading] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (dashboard) {setUpdatedTitle(title);}
|
||||
}, [dashboard, title]);
|
||||
|
||||
const handleLockDashboardToggle = async (): Promise<void> => {
|
||||
if (!id) {return;}
|
||||
setIsDashbordSettingsOpen(false);
|
||||
try {
|
||||
if (isDashboardLocked) {
|
||||
await unlockDashboardV2({ id });
|
||||
toast.success('Dashboard unlocked');
|
||||
} else {
|
||||
await lockDashboardV2({ id });
|
||||
toast.success('Dashboard locked');
|
||||
}
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
};
|
||||
|
||||
const onNameChangeHandler = async (): Promise<void> => {
|
||||
const trimmed = updatedTitle.trim();
|
||||
if (!id || !trimmed || trimmed === title) {
|
||||
setIsRenameDashboardOpen(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsRenameLoading(true);
|
||||
const patch: DashboardtypesJSONPatchOperationDTO[] = [
|
||||
{
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: '/spec/display/name',
|
||||
value: trimmed,
|
||||
},
|
||||
];
|
||||
await patchDashboardV2({ id }, patch);
|
||||
toast.success('Dashboard renamed successfully');
|
||||
setIsRenameDashboardOpen(false);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
setIsRenameDashboardOpen(true);
|
||||
} finally {
|
||||
setIsRenameLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onEmptyWidgetHandler = (): void => {
|
||||
logEvent('Dashboard Detail V2: Add new panel clicked', {
|
||||
dashboardId: id,
|
||||
});
|
||||
toast.info('V2 panel editor coming next');
|
||||
};
|
||||
|
||||
const [state, setCopy] = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
if (state.error) {
|
||||
toast.error(t('something_went_wrong', { ns: 'common' }));
|
||||
}
|
||||
if (state.value) {
|
||||
toast.success(t('success', { ns: 'common' }));
|
||||
}
|
||||
}, [state.error, state.value, t]);
|
||||
|
||||
const dashboardDataJSON = (): string =>
|
||||
JSON.stringify(dashboard ?? {}, null, 2);
|
||||
|
||||
const exportJSON = (): void => {
|
||||
const blob = new Blob([dashboardDataJSON()], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${title || 'dashboard'}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const onConfigureClick = (): void => {
|
||||
setIsSettingsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const onSettingsDrawerClose = (): void => {
|
||||
setIsSettingsDrawerOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="dashboard-description-container">
|
||||
<DashboardHeader title={title} image={image} />
|
||||
<section className="dashboard-details">
|
||||
<div className="left-section">
|
||||
<img src={image} alt="dashboard-img" className="dashboard-img" />
|
||||
<Tooltip title={title.length > 30 ? title : ''}>
|
||||
<Typography.Text
|
||||
className="dashboard-title"
|
||||
data-testid="dashboard-title"
|
||||
>
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
|
||||
{isPublicDashboard && (
|
||||
<Tooltip title="This dashboard is publicly accessible">
|
||||
<Globe size={14} className="public-dashboard-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isDashboardLocked && (
|
||||
<Tooltip title="This dashboard is locked">
|
||||
<LockKeyhole size={14} className="lock-dashboard-icon" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="right-section">
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
<Popover
|
||||
open={isDashboardSettingsOpen}
|
||||
arrow={false}
|
||||
onOpenChange={(visible): void => setIsDashbordSettingsOpen(visible)}
|
||||
rootClassName="dashboard-settings"
|
||||
content={
|
||||
<div className="menu-content">
|
||||
<section className="section-1">
|
||||
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
|
||||
<Tooltip
|
||||
title={
|
||||
dashboard?.createdBy === 'integration' &&
|
||||
'Dashboards created by integrations cannot be unlocked'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LockKeyhole size={14} />}
|
||||
disabled={dashboard?.createdBy === 'integration'}
|
||||
onClick={handleLockDashboardToggle}
|
||||
data-testid="lock-unlock-dashboard"
|
||||
>
|
||||
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!isDashboardLocked && editDashboard && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PenLine size={14} />}
|
||||
onClick={(): void => {
|
||||
setIsRenameDashboardOpen(true);
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Fullscreen size={14} />}
|
||||
onClick={handle.enter}
|
||||
>
|
||||
Full screen
|
||||
</Button>
|
||||
</section>
|
||||
<section className="section-2">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<FileJson size={14} />}
|
||||
onClick={(): void => {
|
||||
exportJSON();
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Export JSON
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ClipboardCopy size={14} />}
|
||||
onClick={(): void => {
|
||||
setCopy(dashboardDataJSON());
|
||||
setIsDashbordSettingsOpen(false);
|
||||
}}
|
||||
>
|
||||
Copy as JSON
|
||||
</Button>
|
||||
</section>
|
||||
<section className="delete-dashboard">
|
||||
<DeleteButton
|
||||
createdBy={dashboard?.createdBy || ''}
|
||||
name={title}
|
||||
id={id}
|
||||
isLocked={isDashboardLocked}
|
||||
routeToListPage
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button
|
||||
icon={<Ellipsis size={14} />}
|
||||
type="text"
|
||||
className="icons"
|
||||
data-testid="options"
|
||||
/>
|
||||
</Popover>
|
||||
{!isDashboardLocked && editDashboard && (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
className="configure-button"
|
||||
icon={<ConfigureIcon />}
|
||||
data-testid="show-drawer"
|
||||
onClick={onConfigureClick}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
onClose={onSettingsDrawerClose}
|
||||
>
|
||||
<DashboardSettingsV2
|
||||
dashboard={dashboard}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
</SettingsDrawer>
|
||||
</>
|
||||
)}
|
||||
{!isDashboardLocked && addPanelPermission && (
|
||||
<Button
|
||||
className="add-panel-btn"
|
||||
onClick={onEmptyWidgetHandler}
|
||||
icon={<Plus size="md" />}
|
||||
type="primary"
|
||||
data-testid="add-panel-header"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{tags.length > 0 && (
|
||||
<div className="dashboard-tags">
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} className="tag">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty(description) && (
|
||||
<section className="dashboard-description-section">{description}</section>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={isRenameDashboardOpen}
|
||||
title="Rename Dashboard"
|
||||
onOk={onNameChangeHandler}
|
||||
onCancel={(): void => {
|
||||
setIsRenameDashboardOpen(false);
|
||||
}}
|
||||
rootClassName="rename-dashboard"
|
||||
footer={
|
||||
<div className="dashboard-rename">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Check size={14} />}
|
||||
className="rename-btn"
|
||||
onClick={onNameChangeHandler}
|
||||
disabled={isRenameLoading}
|
||||
>
|
||||
Rename Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<X size={14} />}
|
||||
className="cancel-btn"
|
||||
onClick={(): void => setIsRenameDashboardOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="dashboard-content">
|
||||
<Typography.Text className="name-text">Enter a new name</Typography.Text>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className="dashboard-name-input"
|
||||
value={updatedTitle}
|
||||
onChange={(e): void => setUpdatedTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardDescriptionV2;
|
||||
@@ -0,0 +1,227 @@
|
||||
.overviewContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.overviewSettings {
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l1-border);
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.crossPanelSyncGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncSectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.crossPanelSyncInfoIcon {
|
||||
cursor: help;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipTitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDescription {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.crossPanelSyncTooltipDocLink {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--primary-background);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
& + & {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.crossPanelSyncInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.crossPanelSyncTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.crossPanelSyncDescription {
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.nameIconInput {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dashboardImageInput {
|
||||
:global(.ant-select-selector) {
|
||||
display: flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 6px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l3-background) !important;
|
||||
|
||||
:global(.ant-select-selection-item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
&:global(.ant-select-dropdown) {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
:global(.ant-select-item) {
|
||||
padding: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
:global(.ant-select-item-option-content) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItemImage {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.dashboardNameInput {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.dashboardName {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.descriptionTextArea {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.overviewSettingsFooter {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: -webkit-fill-available;
|
||||
padding: 12px 16px 12px 0px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.unsavedDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--primary-background);
|
||||
box-shadow: 0px 0px 6px 0px
|
||||
color-mix(in srgb, var(--primary-background) 40%, transparent);
|
||||
}
|
||||
|
||||
.unsavedChanges {
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.footerActionBtns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.discardBtn {
|
||||
margin: '16px 0';
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
margin: 0px !important;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TODO: migrate Radio to @signozhq/ui/radio-group
|
||||
import { Col, Input, Radio, Select, Space, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AddTags from 'container/DashboardContainer/DashboardSettings/General/AddBadges';
|
||||
import { useDashboardCursorSyncMode } from 'hooks/dashboard/useDashboardCursorSyncMode';
|
||||
import { useSyncTooltipFilterMode } from 'hooks/dashboard/useSyncTooltipFilterMode';
|
||||
import {
|
||||
DashboardCursorSync,
|
||||
SyncTooltipFilterMode,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { Check, ExternalLink, SolidInfoCircle, X } from '@signozhq/icons';
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
TagtypesPostableTagDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import styles from './GeneralSettings.module.scss';
|
||||
import { Button } from './styles';
|
||||
import { Base64Icons } from './utils';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Events } from 'constants/events';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import type { V2Dashboard } from '../../utils';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
interface Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
// Convert V2 tags ({key, value}[]) into "key:value" strings for the V1
|
||||
// AddTags component (which expects string[]), and back on save.
|
||||
//
|
||||
// V2 tags require both `key` and `value` to be non-empty server-side
|
||||
// (returns `tag_invalid_value` otherwise). To preserve the V1 single-word
|
||||
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
|
||||
// collapsed back to just `x` for display.
|
||||
function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
|
||||
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
|
||||
}
|
||||
|
||||
function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {
|
||||
return tagStrings
|
||||
.map((s) => {
|
||||
const trimmed = s.trim();
|
||||
const idx = trimmed.indexOf(':');
|
||||
if (idx === -1) {
|
||||
return { key: trimmed, value: trimmed };
|
||||
}
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
return { key, value: value || key };
|
||||
})
|
||||
.filter((t) => t.key.length > 0);
|
||||
}
|
||||
|
||||
function GeneralDashboardSettingsV2({
|
||||
dashboard,
|
||||
onRefetch,
|
||||
}: Props): JSX.Element {
|
||||
const id = dashboard?.id ?? '';
|
||||
|
||||
const [cursorSyncMode, setCursorSyncMode] = useDashboardCursorSyncMode(id);
|
||||
const [syncTooltipFilterMode, setSyncTooltipFilterMode] =
|
||||
useSyncTooltipFilterMode(id);
|
||||
|
||||
const title = dashboard?.spec?.display?.name ?? '';
|
||||
const description = dashboard?.spec?.display?.description ?? '';
|
||||
const image = dashboard?.image || Base64Icons[0];
|
||||
const tagsAsStrings = useMemo(
|
||||
() => tagsToStrings(dashboard?.tags ?? []),
|
||||
[dashboard?.tags],
|
||||
);
|
||||
|
||||
const [updatedTitle, setUpdatedTitle] = useState<string>(title);
|
||||
const [updatedTags, setUpdatedTags] = useState<string[]>(tagsAsStrings);
|
||||
const [updatedDescription, setUpdatedDescription] =
|
||||
useState<string>(description);
|
||||
const [updatedImage, setUpdatedImage] = useState<string>(image);
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [numberOfUnsavedChanges, setNumberOfUnsavedChanges] =
|
||||
useState<number>(0);
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
// Sync state when dashboard refetches after a save
|
||||
useEffect(() => {
|
||||
setUpdatedTitle(title);
|
||||
setUpdatedDescription(description);
|
||||
setUpdatedImage(image);
|
||||
setUpdatedTags(tagsAsStrings);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard?.updatedAt]);
|
||||
|
||||
const buildPatch = (): DashboardtypesJSONPatchOperationDTO[] => {
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
const replace = (
|
||||
path: string,
|
||||
value: unknown,
|
||||
): DashboardtypesJSONPatchOperationDTO => ({
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path,
|
||||
value,
|
||||
});
|
||||
|
||||
if (updatedTitle !== title) {
|
||||
ops.push(replace('/spec/display/name', updatedTitle));
|
||||
}
|
||||
if (updatedDescription !== description) {
|
||||
ops.push(replace('/spec/display/description', updatedDescription));
|
||||
}
|
||||
if (updatedImage !== image) {
|
||||
ops.push(replace('/image', updatedImage));
|
||||
}
|
||||
if (!isEqual(updatedTags, tagsAsStrings)) {
|
||||
ops.push(replace('/tags', stringsToTags(updatedTags)));
|
||||
}
|
||||
return ops;
|
||||
};
|
||||
|
||||
const onSaveHandler = async (): Promise<void> => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const ops = buildPatch();
|
||||
if (ops.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id }, ops);
|
||||
toast.success('Dashboard updated');
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let n = 0;
|
||||
const initialValues = [title, description, tagsAsStrings, image];
|
||||
const updatedValues = [
|
||||
updatedTitle,
|
||||
updatedDescription,
|
||||
updatedTags,
|
||||
updatedImage,
|
||||
];
|
||||
initialValues.forEach((val, index) => {
|
||||
if (!isEqual(val, updatedValues[index])) {
|
||||
n += 1;
|
||||
}
|
||||
});
|
||||
setNumberOfUnsavedChanges(n);
|
||||
}, [
|
||||
description,
|
||||
image,
|
||||
tagsAsStrings,
|
||||
title,
|
||||
updatedDescription,
|
||||
updatedImage,
|
||||
updatedTags,
|
||||
updatedTitle,
|
||||
]);
|
||||
|
||||
const discardHandler = (): void => {
|
||||
setUpdatedTitle(title);
|
||||
setUpdatedImage(image);
|
||||
setUpdatedTags(tagsAsStrings);
|
||||
setUpdatedDescription(description);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.overviewContent}>
|
||||
<Col className={styles.overviewSettings}>
|
||||
<Space
|
||||
direction="vertical"
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '21px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Dashboard Name</Typography>
|
||||
<section className={styles.nameIconInput}>
|
||||
<Select
|
||||
defaultActiveFirstOption
|
||||
data-testid="dashboard-image"
|
||||
suffixIcon={null}
|
||||
rootClassName={styles.dashboardImageInput}
|
||||
value={updatedImage}
|
||||
onChange={(value: string): void => setUpdatedImage(value)}
|
||||
>
|
||||
{Base64Icons.map((icon) => (
|
||||
<Option value={icon} key={icon}>
|
||||
<img
|
||||
src={icon}
|
||||
alt="dashboard-icon"
|
||||
className={styles.listItemImage}
|
||||
/>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
data-testid="dashboard-name"
|
||||
className={styles.dashboardNameInput}
|
||||
value={updatedTitle}
|
||||
onChange={(e): void => setUpdatedTitle(e.target.value)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Description</Typography>
|
||||
<Input.TextArea
|
||||
data-testid="dashboard-desc"
|
||||
rows={6}
|
||||
value={updatedDescription}
|
||||
className={styles.descriptionTextArea}
|
||||
onChange={(e): void => setUpdatedDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography className={styles.dashboardName}>Tags</Typography>
|
||||
<AddTags tags={updatedTags} setTags={setUpdatedTags} />
|
||||
</div>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col className={`${styles.overviewSettings} ${styles.crossPanelSyncGroup}`}>
|
||||
<div className={styles.crossPanelSyncSectionHeader}>
|
||||
<Typography.Text className={styles.crossPanelSyncSectionTitle}>
|
||||
Cross-Panel Sync
|
||||
</Typography.Text>
|
||||
<Tooltip
|
||||
title={
|
||||
<div className={styles.crossPanelSyncTooltipContent}>
|
||||
<strong className={styles.crossPanelSyncTooltipTitle}>
|
||||
Cross-Panel Sync
|
||||
</strong>
|
||||
<span className={styles.crossPanelSyncTooltipDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</span>
|
||||
<a
|
||||
href="https://signoz.io/docs/dashboards/interactivity/#cross-panel-sync"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.crossPanelSyncTooltipDocLink}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
placement="top"
|
||||
mouseEnterDelay={0.5}
|
||||
>
|
||||
<SolidInfoCircle size="md" className={styles.crossPanelSyncInfoIcon} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
Sync Mode
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.crossPanelSyncDescription}>
|
||||
Sync crosshair and tooltip across all the dashboard panels
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={cursorSyncMode}
|
||||
onChange={(e): void => {
|
||||
setCursorSyncMode(e.target.value as DashboardCursorSync);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={DashboardCursorSync.None}>No Sync</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Crosshair}>
|
||||
Crosshair
|
||||
</Radio.Button>
|
||||
<Radio.Button value={DashboardCursorSync.Tooltip}>Tooltip</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
{cursorSyncMode === DashboardCursorSync.Tooltip && (
|
||||
<div className={styles.crossPanelSyncRow}>
|
||||
<div className={styles.crossPanelSyncInfo}>
|
||||
<Typography.Text className={styles.crossPanelSyncTitle}>
|
||||
Synced Tooltip Series
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.crossPanelSyncDescription}>
|
||||
Show only series that intersect on group-by, or every series with the
|
||||
matching ones highlighted
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={syncTooltipFilterMode}
|
||||
onChange={(e): void => {
|
||||
logEvent(Events.TOOLTIP_SYNC_MODE_CHANGED, {
|
||||
path: getAbsoluteUrl(window.location.pathname),
|
||||
mode: e.target.value,
|
||||
});
|
||||
setSyncTooltipFilterMode(e.target.value as SyncTooltipFilterMode);
|
||||
}}
|
||||
>
|
||||
<Radio.Button value={SyncTooltipFilterMode.All}>All</Radio.Button>
|
||||
<Radio.Button value={SyncTooltipFilterMode.Filtered}>
|
||||
Filtered
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
{numberOfUnsavedChanges > 0 && (
|
||||
<div className={styles.overviewSettingsFooter}>
|
||||
<div className={styles.unsaved}>
|
||||
<div className={styles.unsavedDot} />
|
||||
<Typography.Text className={styles.unsavedChanges}>
|
||||
{numberOfUnsavedChanges} unsaved change
|
||||
{numberOfUnsavedChanges > 1 && 's'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.footerActionBtns}>
|
||||
<Button
|
||||
disabled={isSaving}
|
||||
icon={<X size={14} />}
|
||||
onClick={discardHandler}
|
||||
type="text"
|
||||
className={styles.discardBtn}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
style={{ margin: '16px 0' }}
|
||||
disabled={isSaving}
|
||||
loading={isSaving}
|
||||
icon={<Check size={14} />}
|
||||
data-testid="save-dashboard-config"
|
||||
onClick={onSaveHandler}
|
||||
type="primary"
|
||||
className={styles.saveBtn}
|
||||
>
|
||||
{t('save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GeneralDashboardSettingsV2;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Button as ButtonComponent, Drawer } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
margin-top: 0.5rem;
|
||||
`;
|
||||
|
||||
export const Button = styled(ButtonComponent)`
|
||||
&&& {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
export const DrawerContainer = styled(Drawer)`
|
||||
.ant-drawer-header {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
`;
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,67 @@
|
||||
import { Button, Empty, Tabs } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Braces, Globe, Table } from '@signozhq/icons';
|
||||
|
||||
import '../../DashboardContainer/DashboardSettings/DashboardSettingsContent.styles.scss';
|
||||
|
||||
import GeneralDashboardSettingsV2 from './General';
|
||||
import type { V2Dashboard } from '../utils';
|
||||
|
||||
interface Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
function Placeholder({ message }: { message: string }): JSX.Element {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={<Typography.Text>{message}</Typography.Text>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardSettingsV2({ dashboard, onRefetch }: Props): JSX.Element {
|
||||
const items = [
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Table size={14} />}>
|
||||
General
|
||||
</Button>
|
||||
),
|
||||
key: 'general',
|
||||
children: (
|
||||
<GeneralDashboardSettingsV2
|
||||
dashboard={dashboard}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Braces size={14} />}>
|
||||
Variables
|
||||
</Button>
|
||||
),
|
||||
key: 'variables',
|
||||
children: <Placeholder message="V2 dashboard variables coming next." />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Globe size={14} />}>
|
||||
Publish
|
||||
</Button>
|
||||
),
|
||||
key: 'public-dashboard',
|
||||
children: (
|
||||
<Placeholder message="V2 public dashboard publishing coming next." />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return <Tabs items={items} />;
|
||||
}
|
||||
|
||||
export default DashboardSettingsV2;
|
||||
@@ -0,0 +1,4 @@
|
||||
.emptyState {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: var(--bg-vanilla-400, #8993ae);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
background: var(--bg-slate-400, #1d212d);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useMemo } from 'react';
|
||||
import { EllipsisVertical, FolderInput, Trash2 } from '@signozhq/icons';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
|
||||
import type { DeletePanelArgs } from '../hooks/useDeletePanel';
|
||||
import styles from './PanelActionsMenu.module.scss';
|
||||
|
||||
interface Props {
|
||||
panelId: string;
|
||||
currentLayoutIndex: number;
|
||||
sections: DashboardSectionV2[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
function PanelActionsMenu({
|
||||
panelId,
|
||||
currentLayoutIndex,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}: Props): JSX.Element {
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
const result: MenuItem[] = [];
|
||||
|
||||
if (onMovePanel) {
|
||||
const targets = sections.filter(
|
||||
(s) => s.title && s.layoutIndex !== currentLayoutIndex,
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
result.push({
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
disabled: true,
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
key: 'move',
|
||||
label: 'Move to section',
|
||||
icon: <FolderInput size={14} />,
|
||||
children: targets.map((s) => ({
|
||||
key: `move-${s.layoutIndex}`,
|
||||
label: s.title,
|
||||
onClick: (): void =>
|
||||
onMovePanel({
|
||||
panelId,
|
||||
fromLayoutIndex: currentLayoutIndex,
|
||||
toLayoutIndex: s.layoutIndex,
|
||||
}),
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (onDeletePanel) {
|
||||
if (result.length > 0) {
|
||||
result.push({ type: 'divider' });
|
||||
}
|
||||
result.push({
|
||||
key: 'delete-panel',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: 'Delete panel',
|
||||
onClick: (): void =>
|
||||
onDeletePanel({ panelId, layoutIndex: currentLayoutIndex }),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [sections, currentLayoutIndex, panelId, onMovePanel, onDeletePanel]);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
aria-label="Panel actions"
|
||||
data-testid={`panel-actions-${panelId}`}
|
||||
// Stop pointer/mouse down from reaching the RGL drag handle this
|
||||
// button lives inside, so opening the menu never starts a panel drag.
|
||||
onPointerDown={(e): void => e.stopPropagation()}
|
||||
onMouseDown={(e): void => e.stopPropagation()}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelActionsMenu;
|
||||
@@ -0,0 +1,22 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.typeButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
border: 1px solid var(--bg-slate-400, #1d212d);
|
||||
border-radius: 4px;
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Modal } from 'antd';
|
||||
import {
|
||||
BarChart,
|
||||
ChartLine,
|
||||
ChartPie,
|
||||
Hash,
|
||||
List,
|
||||
Table,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import styles from './PanelTypeSelectionModalV2.module.scss';
|
||||
|
||||
interface PanelType {
|
||||
pluginKind: string;
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
}
|
||||
|
||||
const PANEL_TYPES: PanelType[] = [
|
||||
{
|
||||
pluginKind: 'signoz/TimeSeriesPanel',
|
||||
label: 'Time Series',
|
||||
icon: <ChartLine size={16} />,
|
||||
},
|
||||
{ pluginKind: 'signoz/NumberPanel', label: 'Value', icon: <Hash size={16} /> },
|
||||
{ pluginKind: 'signoz/TablePanel', label: 'Table', icon: <Table size={16} /> },
|
||||
{
|
||||
pluginKind: 'signoz/BarChartPanel',
|
||||
label: 'Bar Chart',
|
||||
icon: <BarChart size={16} />,
|
||||
},
|
||||
{
|
||||
pluginKind: 'signoz/PieChartPanel',
|
||||
label: 'Pie Chart',
|
||||
icon: <ChartPie size={16} />,
|
||||
},
|
||||
{
|
||||
pluginKind: 'signoz/HistogramPanel',
|
||||
label: 'Histogram',
|
||||
icon: <BarChart size={16} />,
|
||||
},
|
||||
{ pluginKind: 'signoz/ListPanel', label: 'List', icon: <List size={16} /> },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (pluginKind: string) => void;
|
||||
}
|
||||
|
||||
function PanelTypeSelectionModalV2({
|
||||
open,
|
||||
onClose,
|
||||
onSelect,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="Select a panel type"
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
>
|
||||
<div className={styles.grid}>
|
||||
{PANEL_TYPES.map((type) => (
|
||||
<button
|
||||
key={type.pluginKind}
|
||||
type="button"
|
||||
className={styles.typeButton}
|
||||
data-testid={`panel-type-${type.pluginKind}`}
|
||||
onClick={(): void => onSelect(type.pluginKind)}
|
||||
>
|
||||
{type.icon}
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelTypeSelectionModalV2;
|
||||
@@ -0,0 +1,52 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
border: 1px solid var(--bg-slate-400, #1d212d);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--bg-slate-400, #1d212d);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
color: var(--bg-vanilla-400, #8993ae);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bodyKind {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { EllipsisVertical } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
import type { MovePanelArgs } from '../hooks/useMovePanelToSection';
|
||||
import type { DeletePanelArgs } from '../hooks/useDeletePanel';
|
||||
import PanelActionsMenu from '../PanelActionsMenu/PanelActionsMenu';
|
||||
import styles from './PanelV2.module.scss';
|
||||
|
||||
interface Props {
|
||||
panel: DashboardtypesPanelDTO | undefined;
|
||||
panelId: string;
|
||||
/**
|
||||
* Placeholder: true once this panel's section enters the viewport. The panel
|
||||
* query-loading implementation (later PR) will consume this to lazily fetch
|
||||
* data. Currently unused on purpose.
|
||||
*/
|
||||
isVisible?: boolean;
|
||||
/** Section actions — present only in editable sectioned mode. */
|
||||
currentLayoutIndex?: number;
|
||||
sections?: DashboardSectionV2[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
function PanelV2({
|
||||
panel,
|
||||
panelId,
|
||||
isVisible,
|
||||
currentLayoutIndex,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}: Props): JSX.Element {
|
||||
const name = panel?.spec?.display?.name || `Panel ${panelId.slice(0, 6)}`;
|
||||
const description = panel?.spec?.display?.description;
|
||||
const kind = panel?.spec?.plugin?.kind?.replace(/^signoz\//, '') ?? 'unknown';
|
||||
const queryCount = panel?.spec?.queries?.length ?? 0;
|
||||
|
||||
const headerTitle = useMemo(() => {
|
||||
if (!description) {
|
||||
return name;
|
||||
}
|
||||
return (
|
||||
<Tooltip title={description}>
|
||||
<span>{name}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}, [name, description]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.panel}
|
||||
data-panel-visible={isVisible ? 'true' : 'false'}
|
||||
>
|
||||
<div className={`${styles.header} panel-drag-handle`}>
|
||||
<div className={styles.headerLeft}>
|
||||
<Typography.Text className={styles.headerTitle}>
|
||||
{headerTitle}
|
||||
</Typography.Text>
|
||||
<Badge className={styles.badge}>{kind}</Badge>
|
||||
</div>
|
||||
{currentLayoutIndex !== undefined && (onMovePanel || onDeletePanel) ? (
|
||||
<PanelActionsMenu
|
||||
panelId={panelId}
|
||||
currentLayoutIndex={currentLayoutIndex}
|
||||
sections={sections ?? []}
|
||||
onMovePanel={onMovePanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
) : (
|
||||
<EllipsisVertical size={14} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div>
|
||||
<div className={styles.bodyKind}>{kind} panel</div>
|
||||
<div>
|
||||
{queryCount} {queryCount === 1 ? 'query' : 'queries'} · chart rendering
|
||||
coming next
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelV2;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useCallback } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import {
|
||||
addPanelToSectionOps,
|
||||
createDefaultPanel,
|
||||
panelRef,
|
||||
} from '../../../patchOps';
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
sections: DashboardSectionV2[];
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
export interface AddPanelArgs {
|
||||
layoutIndex: number;
|
||||
pluginKind: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new panel and places its item ref at the bottom of the target
|
||||
* section, as one atomic patch. Structure-only: the panel is a valid minimal
|
||||
* placeholder (its query is filled in once the panel editor lands).
|
||||
*/
|
||||
export function useAddPanelToSection({
|
||||
sections,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): (args: AddPanelArgs) => Promise<void> {
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
return useCallback(
|
||||
async ({ layoutIndex, pluginKind }: AddPanelArgs): Promise<void> => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
const target = sections.find((s) => s.layoutIndex === layoutIndex);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panelId = uuid();
|
||||
const nextY = target.items.reduce(
|
||||
(max, i) => Math.max(max, i.y + i.height),
|
||||
0,
|
||||
);
|
||||
|
||||
try {
|
||||
await patchDashboardV2(
|
||||
{ id: dashboardId },
|
||||
addPanelToSectionOps({
|
||||
panelId,
|
||||
panel: createDefaultPanel(pluginKind),
|
||||
layoutIndex,
|
||||
item: {
|
||||
x: 0,
|
||||
y: nextY,
|
||||
width: 6,
|
||||
height: 6,
|
||||
content: { $ref: panelRef(panelId) },
|
||||
},
|
||||
}),
|
||||
);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, onRefetch, showErrorModal],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { removePanelOp, replaceSectionItemsOp } from '../../../patchOps';
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
sections: DashboardSectionV2[];
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
export interface DeletePanelArgs {
|
||||
panelId: string;
|
||||
layoutIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a panel: drops its item ref from the section's items and deletes the
|
||||
* panel from `spec.panels`, as one atomic patch.
|
||||
*/
|
||||
export function useDeletePanel({
|
||||
sections,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): (args: DeletePanelArgs) => Promise<void> {
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
return useCallback(
|
||||
async ({ panelId, layoutIndex }: DeletePanelArgs): Promise<void> => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
const section = sections.find((s) => s.layoutIndex === layoutIndex);
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextItems = section.items.filter((i) => i.id !== panelId);
|
||||
try {
|
||||
await patchDashboardV2({ id: dashboardId }, [
|
||||
replaceSectionItemsOp(layoutIndex, nextItems),
|
||||
removePanelOp(panelId),
|
||||
]);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, onRefetch, showErrorModal],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { movePanelBetweenSectionsOps } from '../../../patchOps';
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
|
||||
export interface MovePanelArgs {
|
||||
panelId: string;
|
||||
fromLayoutIndex: number;
|
||||
toLayoutIndex: number;
|
||||
}
|
||||
|
||||
interface Params {
|
||||
sections: DashboardSectionV2[];
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relocates a panel's item ref from one section to another. The panel itself
|
||||
* stays in `spec.panels`; only the grid item moves, dropped into a free row at
|
||||
* the bottom of the target section. Persisted as one atomic patch.
|
||||
*/
|
||||
export function useMovePanelToSection({
|
||||
sections,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): (args: MovePanelArgs) => Promise<void> {
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
return useCallback(
|
||||
async ({
|
||||
panelId,
|
||||
fromLayoutIndex,
|
||||
toLayoutIndex,
|
||||
}: MovePanelArgs): Promise<void> => {
|
||||
if (!dashboardId || fromLayoutIndex === toLayoutIndex) {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = sections.find((s) => s.layoutIndex === fromLayoutIndex);
|
||||
const target = sections.find((s) => s.layoutIndex === toLayoutIndex);
|
||||
if (!source || !target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const moved = source.items.find((i) => i.id === panelId);
|
||||
if (!moved) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceItems = source.items.filter((i) => i.id !== panelId);
|
||||
// Place at a fresh row at the bottom of the target section.
|
||||
const nextY = target.items.reduce(
|
||||
(max, i) => Math.max(max, i.y + i.height),
|
||||
0,
|
||||
);
|
||||
const targetItems = [...target.items, { ...moved, x: 0, y: nextY }];
|
||||
|
||||
try {
|
||||
await patchDashboardV2(
|
||||
{ id: dashboardId },
|
||||
movePanelBetweenSectionsOps({
|
||||
sourceIndex: fromLayoutIndex,
|
||||
sourceItems,
|
||||
targetIndex: toLayoutIndex,
|
||||
targetItems,
|
||||
}),
|
||||
);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, onRefetch, showErrorModal],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.addButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--bg-slate-400, #1d212d);
|
||||
border-radius: 4px;
|
||||
color: var(--bg-vanilla-400, #8993ae);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-robin-500);
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
import { useAddSection } from '../hooks/useAddSection';
|
||||
import { useFirstSectionMigration } from '../hooks/useFirstSectionMigration';
|
||||
import FirstSectionMigrationModal from '../FirstSectionMigrationModal';
|
||||
import styles from './AddSectionControl.module.scss';
|
||||
|
||||
const DEFAULT_SECTION_TITLE = 'New section';
|
||||
|
||||
interface Props {
|
||||
sections: DashboardSectionV2[];
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
dashboardId: string | undefined;
|
||||
isSectioned: boolean;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
function AddSectionControl({
|
||||
sections,
|
||||
layouts,
|
||||
dashboardId,
|
||||
isSectioned,
|
||||
onRefetch,
|
||||
}: Props): JSX.Element {
|
||||
const [isMigrationOpen, setIsMigrationOpen] = useState(false);
|
||||
const { addSection } = useAddSection({ layouts, dashboardId, onRefetch });
|
||||
const { migrate, isSaving } = useFirstSectionMigration({
|
||||
sections,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
});
|
||||
|
||||
// Free-flowing dashboard with existing panels → must migrate before sections
|
||||
// can coexist (every panel must belong to a section once any exists).
|
||||
const needsMigration =
|
||||
!isSectioned && sections.some((s) => s.items.length > 0);
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (needsMigration) {
|
||||
setIsMigrationOpen(true);
|
||||
return;
|
||||
}
|
||||
void addSection(DEFAULT_SECTION_TITLE);
|
||||
};
|
||||
|
||||
const handleConfirmMigration = async (): Promise<void> => {
|
||||
await migrate(DEFAULT_SECTION_TITLE);
|
||||
setIsMigrationOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addButton}
|
||||
onClick={handleClick}
|
||||
data-testid="add-section"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add section
|
||||
</button>
|
||||
<FirstSectionMigrationModal
|
||||
open={isMigrationOpen}
|
||||
isSaving={isSaving}
|
||||
onClose={(): void => setIsMigrationOpen(false)}
|
||||
onConfirm={handleConfirmMigration}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddSectionControl;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when the user adds the first section to a free-flowing dashboard that
|
||||
* already has panels. Confirms grouping the existing panels into a section
|
||||
* before proceeding.
|
||||
*/
|
||||
function FirstSectionMigrationModal({
|
||||
open,
|
||||
isSaving,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: Props): JSX.Element {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="Group panels into sections?"
|
||||
onCancel={onClose}
|
||||
onOk={onConfirm}
|
||||
okText="Continue"
|
||||
okButtonProps={{ disabled: isSaving, 'data-testid': 'confirm-migration' }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Typography.Text>
|
||||
This dashboard's panels are currently free-flowing. Adding a section
|
||||
will move the existing panels into their own section, and a new empty
|
||||
section will be added below. You can rename sections afterwards.
|
||||
</Typography.Text>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default FirstSectionMigrationModal;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
initialValue: string;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (title: string) => void;
|
||||
}
|
||||
|
||||
function RenameSectionModal({
|
||||
open,
|
||||
initialValue,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: Props): JSX.Element {
|
||||
const [value, setValue] = useState<string>(initialValue);
|
||||
|
||||
// Reseed the field each time the modal opens.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValue(initialValue);
|
||||
}
|
||||
}, [open, initialValue]);
|
||||
|
||||
const submit = (): void => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
onSubmit(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="Rename section"
|
||||
onCancel={onClose}
|
||||
onOk={submit}
|
||||
okText="Rename"
|
||||
okButtonProps={{ disabled: isSaving || !value.trim() }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Input
|
||||
testId="rename-section-input"
|
||||
autoFocus
|
||||
value={value}
|
||||
maxLength={120}
|
||||
placeholder="Section name"
|
||||
onChange={(e): void => setValue(e.target.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default RenameSectionModal;
|
||||
@@ -0,0 +1,9 @@
|
||||
.section {
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dragging {
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
|
||||
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
|
||||
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
import { useRenameSection } from '../hooks/useRenameSection';
|
||||
import { useToggleSectionCollapse } from '../hooks/useToggleSectionCollapse';
|
||||
import { useDeleteSection } from '../hooks/useDeleteSection';
|
||||
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
|
||||
import type { AddPanelArgs } from '../../Panel/hooks/useAddPanelToSection';
|
||||
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
|
||||
import PanelTypeSelectionModalV2 from '../../Panel/PanelTypeSelectionModalV2/PanelTypeSelectionModalV2';
|
||||
import RenameSectionModal from '../RenameSectionModal';
|
||||
import SectionGrid from '../SectionGrid/SectionGrid';
|
||||
import SectionHeader, { type SectionDragHandle } from '../SectionHeader/SectionHeader';
|
||||
import styles from './Section.module.scss';
|
||||
|
||||
interface Props {
|
||||
section: DashboardSectionV2;
|
||||
dashboardId: string | undefined;
|
||||
isEditable: boolean;
|
||||
onRefetch: () => void;
|
||||
/** All sections + move handler, for the per-panel "Move to section" action. */
|
||||
sections?: DashboardSectionV2[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
/** Adds a panel to this section; present only in editable sectioned mode. */
|
||||
onAddPanel?: (args: AddPanelArgs) => void;
|
||||
/** Deletes a panel from this section. */
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow sections. */
|
||||
dragHandle?: SectionDragHandle;
|
||||
}
|
||||
|
||||
function Section({
|
||||
section,
|
||||
dashboardId,
|
||||
isEditable,
|
||||
onRefetch,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onAddPanel,
|
||||
onDeletePanel,
|
||||
dragHandle,
|
||||
}: Props): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
|
||||
// true once the section scrolls into (or near) the viewport.
|
||||
const isVisible = useIntersectionObserver(containerRef, {
|
||||
rootMargin: '200px',
|
||||
});
|
||||
|
||||
const { open, toggle } = useToggleSectionCollapse({
|
||||
layoutIndex: section.layoutIndex,
|
||||
initialOpen: section.open,
|
||||
dashboardId,
|
||||
});
|
||||
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const { rename, isSaving } = useRenameSection({
|
||||
layoutIndex: section.layoutIndex,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
});
|
||||
|
||||
const handleRenameSubmit = async (title: string): Promise<void> => {
|
||||
const ok = await rename(title);
|
||||
if (ok) {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [isAddingPanel, setIsAddingPanel] = useState(false);
|
||||
const handleSelectPanelType = (pluginKind: string): void => {
|
||||
onAddPanel?.({ layoutIndex: section.layoutIndex, pluginKind });
|
||||
setIsAddingPanel(false);
|
||||
};
|
||||
|
||||
const { deleteSection } = useDeleteSection({
|
||||
section,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
});
|
||||
const confirmDeleteSection = (): void => {
|
||||
Modal.confirm({
|
||||
title: `Delete section "${section.title ?? ''}"?`,
|
||||
content: 'Panels in this section will be removed.',
|
||||
okText: 'Delete',
|
||||
okButtonProps: { danger: true },
|
||||
centered: true,
|
||||
onOk: () => deleteSection(),
|
||||
});
|
||||
};
|
||||
|
||||
const grid = (
|
||||
<SectionGrid
|
||||
items={section.items}
|
||||
layoutIndex={section.layoutIndex}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
isVisible={isVisible}
|
||||
sections={sections}
|
||||
onMovePanel={onMovePanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!section.title) {
|
||||
// Untitled section — just the grid (no header chrome), but still observed
|
||||
// for the viewport signal.
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
data-section-layout-index={section.layoutIndex}
|
||||
>
|
||||
{grid}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.section}
|
||||
data-testid={`dashboard-section-${section.id}`}
|
||||
data-section-layout-index={section.layoutIndex}
|
||||
>
|
||||
<SectionHeader
|
||||
sectionId={section.id}
|
||||
title={section.title}
|
||||
open={open}
|
||||
onToggle={toggle}
|
||||
repeatVariable={section.repeatVariable}
|
||||
dragHandle={dragHandle}
|
||||
onRename={isEditable ? (): void => setIsRenaming(true) : undefined}
|
||||
onAddPanel={
|
||||
isEditable && onAddPanel ? (): void => setIsAddingPanel(true) : undefined
|
||||
}
|
||||
onDeleteSection={isEditable ? confirmDeleteSection : undefined}
|
||||
/>
|
||||
{open ? grid : null}
|
||||
<RenameSectionModal
|
||||
open={isRenaming}
|
||||
initialValue={section.title}
|
||||
isSaving={isSaving}
|
||||
onClose={(): void => setIsRenaming(false)}
|
||||
onSubmit={handleRenameSubmit}
|
||||
/>
|
||||
<PanelTypeSelectionModalV2
|
||||
open={isAddingPanel}
|
||||
onClose={(): void => setIsAddingPanel(false)}
|
||||
onSelect={handleSelectPanelType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Section;
|
||||
@@ -0,0 +1,16 @@
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
color: var(--bg-vanilla-400, #8993ae);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-vanilla-100, #fff);
|
||||
background: var(--bg-slate-400, #1d212d);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useMemo } from 'react';
|
||||
import { EllipsisVertical, PenLine, Plus, Trash2 } from '@signozhq/icons';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import styles from './SectionActionsMenu.module.scss';
|
||||
|
||||
interface Props {
|
||||
sectionId: string;
|
||||
onAddPanel?: () => void;
|
||||
onRename?: () => void;
|
||||
onDeleteSection?: () => void;
|
||||
}
|
||||
|
||||
function SectionActionsMenu({
|
||||
sectionId,
|
||||
onAddPanel,
|
||||
onRename,
|
||||
onDeleteSection,
|
||||
}: Props): JSX.Element {
|
||||
const items = useMemo<MenuItem[]>(() => {
|
||||
const result: MenuItem[] = [];
|
||||
if (onAddPanel) {
|
||||
result.push({
|
||||
key: 'add-panel',
|
||||
icon: <Plus size={14} />,
|
||||
label: 'Add panel',
|
||||
onClick: onAddPanel,
|
||||
});
|
||||
}
|
||||
if (onRename) {
|
||||
result.push({
|
||||
key: 'rename',
|
||||
icon: <PenLine size={14} />,
|
||||
label: 'Rename section',
|
||||
onClick: onRename,
|
||||
});
|
||||
}
|
||||
if (onDeleteSection) {
|
||||
result.push(
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'delete-section',
|
||||
danger: true,
|
||||
icon: <Trash2 size={14} />,
|
||||
label: 'Delete section',
|
||||
onClick: onDeleteSection,
|
||||
},
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [onAddPanel, onRename, onDeleteSection]);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
aria-label="Section actions"
|
||||
data-testid={`dashboard-section-actions-${sectionId}`}
|
||||
>
|
||||
<EllipsisVertical size={14} />
|
||||
</button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionActionsMenu;
|
||||
@@ -0,0 +1,7 @@
|
||||
.preview {
|
||||
border: 1px solid var(--bg-robin-500);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 40%);
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
import SectionHeader from '../SectionHeader/SectionHeader';
|
||||
import styles from './SectionDragPreview.module.scss';
|
||||
|
||||
interface Props {
|
||||
section: DashboardSectionV2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight preview rendered inside the DragOverlay while a section is being
|
||||
* dragged. Deliberately header-only (no react-grid-layout) so the overlay is
|
||||
* cheap and never triggers RGL width re-measurement.
|
||||
*/
|
||||
function SectionDragPreview({ section }: Props): JSX.Element {
|
||||
const panelCount = section.items.length;
|
||||
const title = `${section.title ?? ''} · ${panelCount} ${
|
||||
panelCount === 1 ? 'panel' : 'panels'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<SectionHeader
|
||||
sectionId={`${section.id}-preview`}
|
||||
title={title}
|
||||
open={false}
|
||||
onToggle={(): void => undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionDragPreview;
|
||||
@@ -0,0 +1,12 @@
|
||||
.grid {
|
||||
// Override react-grid-layout's default red drag/resize placeholder with the
|
||||
// SigNoz brand blue.
|
||||
:global(.react-grid-item.react-grid-placeholder) {
|
||||
background: var(--bg-robin-500);
|
||||
opacity: 0.2;
|
||||
border-radius: 4px;
|
||||
transition-duration: 100ms;
|
||||
z-index: 2;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import GridLayout, {
|
||||
type ItemCallback,
|
||||
WidthProvider,
|
||||
type Layout,
|
||||
} from 'react-grid-layout';
|
||||
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
import { usePersistLayout } from '../hooks/usePersistLayout';
|
||||
import type { MovePanelArgs } from '../../Panel/hooks/useMovePanelToSection';
|
||||
import type { DeletePanelArgs } from '../../Panel/hooks/useDeletePanel';
|
||||
import PanelV2 from '../../Panel/PanelV2/PanelV2';
|
||||
import styles from './SectionGrid.module.scss';
|
||||
|
||||
const ResponsiveGridLayout = WidthProvider(GridLayout);
|
||||
|
||||
interface Props {
|
||||
items: DashboardSectionV2['items'];
|
||||
layoutIndex: number;
|
||||
dashboardId: string | undefined;
|
||||
isEditable: boolean;
|
||||
onRefetch: () => void;
|
||||
/** Forwarded to panels — true when the parent section is in the viewport. */
|
||||
isVisible?: boolean;
|
||||
/** All sections + move handler — present only in editable sectioned mode (panel "Move to section"). */
|
||||
sections?: DashboardSectionV2[];
|
||||
onMovePanel?: (args: MovePanelArgs) => void;
|
||||
onDeletePanel?: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
function SectionGrid({
|
||||
items,
|
||||
layoutIndex,
|
||||
dashboardId,
|
||||
isEditable,
|
||||
onRefetch,
|
||||
isVisible,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onDeletePanel,
|
||||
}: Props): JSX.Element {
|
||||
const rglLayout = useMemo<Layout[]>(
|
||||
() =>
|
||||
items.map((item) => ({
|
||||
i: item.id,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.width,
|
||||
h: item.height,
|
||||
})),
|
||||
[items],
|
||||
);
|
||||
|
||||
const { handleLayoutChange } = usePersistLayout({
|
||||
layoutIndex,
|
||||
items,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
});
|
||||
|
||||
// On drop, if the pointer is released over a different section, move the
|
||||
// panel there instead of repositioning it within this section. RGL clamps
|
||||
// the dragged item visually to this grid, but the pointer is free, so we
|
||||
// hit-test the release point against section containers.
|
||||
const handleDragStop = useCallback<ItemCallback>(
|
||||
// eslint-disable-next-line max-params -- signature fixed by react-grid-layout's ItemCallback
|
||||
(layout, oldItem, _newItem, _placeholder, event) => {
|
||||
// Deterministically hit-test the release point against section
|
||||
// containers (rect-based, so the dragged item on top doesn't interfere).
|
||||
const targetEl = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-section-layout-index]'),
|
||||
).find((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
return (
|
||||
event.clientX >= r.left &&
|
||||
event.clientX <= r.right &&
|
||||
event.clientY >= r.top &&
|
||||
event.clientY <= r.bottom
|
||||
);
|
||||
});
|
||||
const attr = targetEl?.getAttribute('data-section-layout-index');
|
||||
const targetIndex = attr != null ? Number(attr) : null;
|
||||
|
||||
if (onMovePanel && targetIndex != null && targetIndex !== layoutIndex) {
|
||||
onMovePanel({
|
||||
panelId: oldItem.i,
|
||||
fromLayoutIndex: layoutIndex,
|
||||
toLayoutIndex: targetIndex,
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleLayoutChange(layout);
|
||||
},
|
||||
[onMovePanel, layoutIndex, handleLayoutChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveGridLayout
|
||||
className={styles.grid}
|
||||
cols={12}
|
||||
rowHeight={45}
|
||||
autoSize
|
||||
useCSSTransforms
|
||||
layout={rglLayout}
|
||||
draggableHandle=".panel-drag-handle"
|
||||
isDraggable={isEditable}
|
||||
isResizable={isEditable}
|
||||
onDragStop={handleDragStop}
|
||||
onResizeStop={handleLayoutChange}
|
||||
margin={[8, 8]}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id}>
|
||||
<PanelV2
|
||||
panel={item.panel}
|
||||
panelId={item.id}
|
||||
isVisible={isVisible}
|
||||
currentLayoutIndex={layoutIndex}
|
||||
sections={isEditable ? sections : undefined}
|
||||
onMovePanel={isEditable ? onMovePanel : undefined}
|
||||
onDeletePanel={isEditable ? onDeletePanel : undefined}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ResponsiveGridLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionGrid;
|
||||
@@ -0,0 +1,52 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
|
||||
&.headerOpen {
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
}
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--bg-vanilla-400, #8993ae);
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-left: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.repeatBadge {
|
||||
margin-left: 8px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { DraggableAttributes } from '@dnd-kit/core';
|
||||
import type { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities';
|
||||
import { ChevronDown, ChevronRight, GripVertical } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import SectionActionsMenu from '../SectionActionsMenu/SectionActionsMenu';
|
||||
import styles from './SectionHeader.module.scss';
|
||||
|
||||
export interface SectionDragHandle {
|
||||
attributes: DraggableAttributes;
|
||||
listeners: SyntheticListenerMap | undefined;
|
||||
setActivatorNodeRef: (element: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sectionId: string;
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
repeatVariable?: string;
|
||||
dragHandle?: SectionDragHandle;
|
||||
onRename?: () => void;
|
||||
onAddPanel?: () => void;
|
||||
onDeleteSection?: () => void;
|
||||
}
|
||||
|
||||
function SectionHeader({
|
||||
sectionId,
|
||||
title,
|
||||
open,
|
||||
onToggle,
|
||||
repeatVariable,
|
||||
dragHandle,
|
||||
onRename,
|
||||
onAddPanel,
|
||||
onDeleteSection,
|
||||
}: Props): JSX.Element {
|
||||
const hasActions = !!(onAddPanel || onRename || onDeleteSection);
|
||||
return (
|
||||
<div className={`${styles.header} ${open ? styles.headerOpen : ''}`}>
|
||||
{dragHandle ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dragHandle}
|
||||
ref={dragHandle.setActivatorNodeRef}
|
||||
aria-label="Drag to reorder section"
|
||||
data-testid={`dashboard-section-drag-${sectionId}`}
|
||||
{...dragHandle.attributes}
|
||||
{...dragHandle.listeners}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggle}
|
||||
onClick={onToggle}
|
||||
data-testid={`dashboard-section-toggle-${sectionId}`}
|
||||
>
|
||||
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
{repeatVariable ? (
|
||||
<Typography.Text className={styles.repeatBadge}>
|
||||
(repeats per ${repeatVariable})
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</button>
|
||||
{hasActions ? (
|
||||
<SectionActionsMenu
|
||||
sectionId={sectionId}
|
||||
onAddPanel={onAddPanel}
|
||||
onRename={onRename}
|
||||
onDeleteSection={onDeleteSection}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionHeader;
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useMemo } from 'react';
|
||||
import { closestCenter, DndContext, DragOverlay } from '@dnd-kit/core';
|
||||
import {
|
||||
restrictToParentElement,
|
||||
restrictToVerticalAxis,
|
||||
} from '@dnd-kit/modifiers';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { DashboardSectionV2 } from '../../utils';
|
||||
import { useSectionDragReorder } from './hooks/useSectionDragReorder';
|
||||
import { useMovePanelToSection } from '../Panel/hooks/useMovePanelToSection';
|
||||
import { useAddPanelToSection } from '../Panel/hooks/useAddPanelToSection';
|
||||
import { useDeletePanel } from '../Panel/hooks/useDeletePanel';
|
||||
import Section from './Section/Section';
|
||||
import SectionDragPreview from './SectionDragPreview/SectionDragPreview';
|
||||
import SortableSection from './SortableSection';
|
||||
|
||||
interface Props {
|
||||
sections: DashboardSectionV2[];
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
dashboardId: string | undefined;
|
||||
isEditable: boolean;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
function SectionList({
|
||||
sections,
|
||||
layouts,
|
||||
dashboardId,
|
||||
isEditable,
|
||||
onRefetch,
|
||||
}: Props): JSX.Element {
|
||||
const {
|
||||
sensors,
|
||||
orderedSections,
|
||||
activeSection,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragCancel,
|
||||
} = useSectionDragReorder({ sections, layouts, dashboardId, onRefetch });
|
||||
|
||||
const onMovePanel = useMovePanelToSection({
|
||||
sections,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
});
|
||||
const onAddPanel = useAddPanelToSection({ sections, dashboardId, onRefetch });
|
||||
const onDeletePanel = useDeletePanel({ sections, dashboardId, onRefetch });
|
||||
|
||||
// Only titled sections participate in reordering; untitled (free-flow)
|
||||
// blocks render in place without a drag handle.
|
||||
const sortableIds = useMemo(
|
||||
() => orderedSections.filter((s) => s.title).map((s) => s.id),
|
||||
[orderedSections],
|
||||
);
|
||||
|
||||
if (!isEditable) {
|
||||
return (
|
||||
<>
|
||||
{sections.map((section) => (
|
||||
<Section
|
||||
key={section.id}
|
||||
section={section}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragCancel={onDragCancel}
|
||||
>
|
||||
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
|
||||
{orderedSections.map((section) =>
|
||||
section.title ? (
|
||||
<SortableSection
|
||||
key={section.id}
|
||||
section={section}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
sections={sections}
|
||||
onMovePanel={onMovePanel}
|
||||
onAddPanel={onAddPanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
) : (
|
||||
<Section
|
||||
key={section.id}
|
||||
section={section}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
sections={sections}
|
||||
onMovePanel={onMovePanel}
|
||||
onAddPanel={onAddPanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</SortableContext>
|
||||
{/* dropAnimation disabled: optimistic reorder already places the section,
|
||||
so animating the overlay back would cause a visible snap/shake. */}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeSection ? <SectionDragPreview section={activeSection} /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionList;
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import type { DashboardSectionV2 } from '../../utils';
|
||||
import type { MovePanelArgs } from '../Panel/hooks/useMovePanelToSection';
|
||||
import type { AddPanelArgs } from '../Panel/hooks/useAddPanelToSection';
|
||||
import type { DeletePanelArgs } from '../Panel/hooks/useDeletePanel';
|
||||
import Section from './Section/Section';
|
||||
|
||||
interface Props {
|
||||
section: DashboardSectionV2;
|
||||
dashboardId: string | undefined;
|
||||
isEditable: boolean;
|
||||
onRefetch: () => void;
|
||||
sections: DashboardSectionV2[];
|
||||
onMovePanel: (args: MovePanelArgs) => void;
|
||||
onAddPanel: (args: AddPanelArgs) => void;
|
||||
onDeletePanel: (args: DeletePanelArgs) => void;
|
||||
}
|
||||
|
||||
function SortableSection({
|
||||
section,
|
||||
dashboardId,
|
||||
isEditable,
|
||||
onRefetch,
|
||||
sections,
|
||||
onMovePanel,
|
||||
onAddPanel,
|
||||
onDeletePanel,
|
||||
}: Props): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: section.id });
|
||||
|
||||
// dnd-kit drives the drag transform per-frame, so this must be an inline
|
||||
// style — there is no static-stylesheet equivalent for a live transform.
|
||||
// While dragging, the original is hidden (the DragOverlay renders the moving
|
||||
// preview); keeping it in place preserves the gap and lets siblings animate.
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0 : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<Section
|
||||
section={section}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
sections={sections}
|
||||
onMovePanel={onMovePanel}
|
||||
onAddPanel={onAddPanel}
|
||||
onDeletePanel={onDeletePanel}
|
||||
dragHandle={{ attributes, listeners, setActivatorNodeRef }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SortableSection;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { addSectionOp, newGridLayout, reorderLayoutsOp } from '../../../patchOps';
|
||||
|
||||
interface Params {
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
addSection: (title: string) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends an empty titled section. When the dashboard has no layouts yet, the
|
||||
* layouts array is created via a `replace` (an `add` to a missing/empty array
|
||||
* pointer is unreliable); otherwise a new Grid is appended.
|
||||
*/
|
||||
export function useAddSection({
|
||||
layouts,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): Result {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const addSection = useCallback(
|
||||
async (title: string): Promise<void> => {
|
||||
const trimmed = title.trim();
|
||||
if (!dashboardId || !trimmed) {
|
||||
return;
|
||||
}
|
||||
const op =
|
||||
!layouts || layouts.length === 0
|
||||
? reorderLayoutsOp([newGridLayout(trimmed)])
|
||||
: addSectionOp(trimmed);
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, [op]);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[layouts, dashboardId, onRefetch, showErrorModal],
|
||||
);
|
||||
|
||||
return { addSection, isSaving };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { removePanelOp, removeSectionOp } from '../../../patchOps';
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
section: DashboardSectionV2;
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
deleteSection: () => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a section: removes its Grid layout and deletes every panel it
|
||||
* contained from `spec.panels` (orphan cleanup), as one atomic patch.
|
||||
*/
|
||||
export function useDeleteSection({
|
||||
section,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): Result {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const deleteSection = useCallback(async (): Promise<void> => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = section.items.map((i) =>
|
||||
removePanelOp(i.id),
|
||||
);
|
||||
ops.push(removeSectionOp(section.layoutIndex));
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, ops);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [section, dashboardId, onRefetch, showErrorModal]);
|
||||
|
||||
return { deleteSection, isSaving };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesJSONPatchOperationDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { addSectionOp, titleUntitledSectionOp } from '../../../patchOps';
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
sections: DashboardSectionV2[];
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
migrate: (newSectionTitle: string) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a free-flowing dashboard into a sectioned one: every existing
|
||||
* untitled layout that holds panels is titled in place ("Section 1", "Section
|
||||
* 2", …), then the brand-new section the user asked for is appended — all in one
|
||||
* atomic patch. Used once the user confirms the migration prompt.
|
||||
*/
|
||||
export function useFirstSectionMigration({
|
||||
sections,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): Result {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const migrate = useCallback(
|
||||
async (newSectionTitle: string): Promise<void> => {
|
||||
const trimmed = newSectionTitle.trim();
|
||||
if (!dashboardId || !trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ops: DashboardtypesJSONPatchOperationDTO[] = [];
|
||||
let counter = 1;
|
||||
sections.forEach((s) => {
|
||||
if (!s.title && s.items.length > 0) {
|
||||
ops.push(titleUntitledSectionOp(s.layoutIndex, `Section ${counter}`));
|
||||
counter += 1;
|
||||
}
|
||||
});
|
||||
ops.push(addSectionOp(trimmed));
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, ops);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[sections, dashboardId, onRefetch, showErrorModal],
|
||||
);
|
||||
|
||||
return { migrate, isSaving };
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { Layout } from 'react-grid-layout';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { replaceSectionItemsOp } from '../../../patchOps';
|
||||
import type { GridItemV2 } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
layoutIndex: number;
|
||||
items: GridItemV2[];
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
handleLayoutChange: (rglLayout: Layout[]) => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/** Maps an RGL layout back onto the section's grid items, preserving panel refs. */
|
||||
function mergeRglLayout(
|
||||
rglLayout: Layout[],
|
||||
items: GridItemV2[],
|
||||
): GridItemV2[] {
|
||||
const byId = new Map(items.map((item) => [item.id, item]));
|
||||
return rglLayout
|
||||
.map((entry) => {
|
||||
const existing = byId.get(entry.i);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...existing,
|
||||
x: entry.x,
|
||||
y: entry.y,
|
||||
width: entry.w,
|
||||
height: entry.h,
|
||||
};
|
||||
})
|
||||
.filter((item): item is GridItemV2 => item !== null);
|
||||
}
|
||||
|
||||
function hasGeometryChanged(next: GridItemV2[], prev: GridItemV2[]): boolean {
|
||||
if (next.length !== prev.length) {
|
||||
return true;
|
||||
}
|
||||
const prevById = new Map(prev.map((item) => [item.id, item]));
|
||||
return next.some((item) => {
|
||||
const before = prevById.get(item.id);
|
||||
if (!before) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
before.x !== item.x ||
|
||||
before.y !== item.y ||
|
||||
before.width !== item.width ||
|
||||
before.height !== item.height
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists panel geometry within a single section. Call the returned handler
|
||||
* from RGL's `onDragStop`/`onResizeStop` (stop events only — not continuous
|
||||
* `onLayoutChange`) to limit network churn.
|
||||
*/
|
||||
export function usePersistLayout({
|
||||
layoutIndex,
|
||||
items,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): Result {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
async (rglLayout: Layout[]): Promise<void> => {
|
||||
if (!dashboardId) {
|
||||
return;
|
||||
}
|
||||
const nextItems = mergeRglLayout(rglLayout, items);
|
||||
if (!hasGeometryChanged(nextItems, items)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, [
|
||||
replaceSectionItemsOp(layoutIndex, nextItems),
|
||||
]);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[dashboardId, items, layoutIndex, onRefetch, showErrorModal],
|
||||
);
|
||||
|
||||
return { handleLayoutChange, isSaving };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { renameSectionOp } from '../../../patchOps';
|
||||
|
||||
interface Params {
|
||||
layoutIndex: number;
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
rename: (title: string) => Promise<boolean>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
/** Renames a section's title via `replace /spec/layouts/<i>/spec/display/title`. */
|
||||
export function useRenameSection({
|
||||
layoutIndex,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): Result {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const rename = useCallback(
|
||||
async (title: string): Promise<boolean> => {
|
||||
const trimmed = title.trim();
|
||||
if (!dashboardId || !trimmed) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await patchDashboardV2({ id: dashboardId }, [
|
||||
renameSectionOp(layoutIndex, trimmed),
|
||||
]);
|
||||
onRefetch();
|
||||
return true;
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
return false;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[dashboardId, layoutIndex, onRefetch, showErrorModal],
|
||||
);
|
||||
|
||||
return { rename, isSaving };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { reorderLayoutsOp } from '../../../patchOps';
|
||||
import type { DashboardSectionV2 } from '../../../utils';
|
||||
|
||||
interface Params {
|
||||
sections: DashboardSectionV2[];
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
dashboardId: string | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
sensors: ReturnType<typeof useSensors>;
|
||||
/** Display order — optimistically reordered on drop so the UI doesn't wait on refetch. */
|
||||
orderedSections: DashboardSectionV2[];
|
||||
/** The section currently being dragged (for the DragOverlay preview), or null. */
|
||||
activeSection: DashboardSectionV2 | null;
|
||||
onDragStart: (event: DragStartEvent) => void;
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
onDragCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns section-reorder drag state. Reorders happen optimistically in local
|
||||
* state (keyed by stable section id) and persist via a single
|
||||
* `replace /spec/layouts` patch; the optimistic order is cleared once fresh
|
||||
* server data arrives. Each section maps 1:1 to a Grid layout via `layoutIndex`,
|
||||
* so the new layouts array is rebuilt by mapping the reordered sections back.
|
||||
*/
|
||||
export function useSectionDragReorder({
|
||||
sections,
|
||||
layouts,
|
||||
dashboardId,
|
||||
onRefetch,
|
||||
}: Params): Result {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [localOrderIds, setLocalOrderIds] = useState<string[] | null>(null);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
// Server data is the source of truth — drop optimistic order whenever it changes.
|
||||
useEffect(() => {
|
||||
setLocalOrderIds(null);
|
||||
}, [sections]);
|
||||
|
||||
const orderedSections = useMemo<DashboardSectionV2[]>(() => {
|
||||
if (!localOrderIds) {
|
||||
return sections;
|
||||
}
|
||||
const byId = new Map(sections.map((s) => [s.id, s]));
|
||||
const ordered = localOrderIds
|
||||
.map((id) => byId.get(id))
|
||||
.filter((s): s is DashboardSectionV2 => s !== undefined);
|
||||
return ordered.length === sections.length ? ordered : sections;
|
||||
}, [sections, localOrderIds]);
|
||||
|
||||
const onDragStart = useCallback((event: DragStartEvent): void => {
|
||||
setActiveId(String(event.active.id));
|
||||
}, []);
|
||||
|
||||
const onDragCancel = useCallback((): void => {
|
||||
setActiveId(null);
|
||||
}, []);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
async (event: DragEndEvent): Promise<void> => {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id || !dashboardId || !layouts) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldIndex = orderedSections.findIndex((s) => s.id === active.id);
|
||||
const newIndex = orderedSections.findIndex((s) => s.id === over.id);
|
||||
if (oldIndex < 0 || newIndex < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newOrdered = arrayMove(orderedSections, oldIndex, newIndex);
|
||||
setLocalOrderIds(newOrdered.map((s) => s.id));
|
||||
|
||||
const newLayouts = newOrdered
|
||||
.map((s) => layouts[s.layoutIndex])
|
||||
.filter((l): l is DashboardtypesLayoutDTO => l !== undefined);
|
||||
|
||||
try {
|
||||
await patchDashboardV2({ id: dashboardId }, [reorderLayoutsOp(newLayouts)]);
|
||||
onRefetch();
|
||||
} catch (error) {
|
||||
setLocalOrderIds(null); // revert optimistic order on failure
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
},
|
||||
[orderedSections, layouts, dashboardId, onRefetch, showErrorModal],
|
||||
);
|
||||
|
||||
const activeSection = useMemo(
|
||||
() => orderedSections.find((s) => s.id === activeId) ?? null,
|
||||
[orderedSections, activeId],
|
||||
);
|
||||
|
||||
return {
|
||||
sensors,
|
||||
orderedSections,
|
||||
activeSection,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragCancel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { patchDashboardV2 } from 'api/generated/services/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import { setSectionCollapseOp } from '../../../patchOps';
|
||||
|
||||
interface Params {
|
||||
layoutIndex: number;
|
||||
initialOpen: boolean;
|
||||
dashboardId: string | undefined;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
open: boolean;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns a section's expand/collapse state. The toggle is optimistic (snappy UI)
|
||||
* and persists to `spec.layouts[i].spec.display.collapse.open` in the
|
||||
* background — no refetch, since collapse is a lightweight UI preference and a
|
||||
* full dashboard refetch would flicker. Reverts on patch failure.
|
||||
*/
|
||||
export function useToggleSectionCollapse({
|
||||
layoutIndex,
|
||||
initialOpen,
|
||||
dashboardId,
|
||||
}: Params): Result {
|
||||
const [open, setOpen] = useState<boolean>(initialOpen);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
// Reconcile with server state when it changes (e.g. after a refetch).
|
||||
useEffect(() => {
|
||||
setOpen(initialOpen);
|
||||
}, [initialOpen]);
|
||||
|
||||
const toggle = useCallback((): void => {
|
||||
setOpen((prev) => {
|
||||
const next = !prev;
|
||||
if (dashboardId) {
|
||||
patchDashboardV2({ id: dashboardId }, [
|
||||
setSectionCollapseOp(layoutIndex, next),
|
||||
]).catch((error) => {
|
||||
setOpen(prev); // revert on failure
|
||||
showErrorModal(error as APIError);
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [dashboardId, layoutIndex, showErrorModal]);
|
||||
|
||||
return { open, toggle };
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Empty } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type {
|
||||
DashboardtypesLayoutDTO,
|
||||
DashboardtypesPanelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { layoutsToSections } from '../utils';
|
||||
import AddSectionControl from './Section/AddSectionControl/AddSectionControl';
|
||||
import Section from './Section/Section/Section';
|
||||
import SectionList from './Section/SectionList';
|
||||
import styles from './GridCardLayoutV2.module.scss';
|
||||
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
|
||||
interface Props {
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null;
|
||||
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined;
|
||||
dashboardId: string | undefined;
|
||||
isEditable: boolean;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
function GridCardLayoutV2({
|
||||
layouts,
|
||||
panels,
|
||||
dashboardId,
|
||||
isEditable,
|
||||
onRefetch,
|
||||
}: Props): JSX.Element {
|
||||
const sections = useMemo(
|
||||
() => layoutsToSections(layouts, panels),
|
||||
[layouts, panels],
|
||||
);
|
||||
|
||||
const isEmpty =
|
||||
sections.length === 0 || sections.every((s) => s.items.length === 0);
|
||||
|
||||
// Sectioned mode = at least one titled layout. Sections then become a
|
||||
// draggable, reorderable list; otherwise the dashboard is a single
|
||||
// free-flowing grid with no section chrome or reordering.
|
||||
const isSectioned = useMemo(() => sections.some((s) => !!s.title), [sections]);
|
||||
|
||||
const renderContent = (): JSX.Element => {
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
<Typography.Text>No panels in this dashboard yet</Typography.Text>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSectioned) {
|
||||
return (
|
||||
<SectionList
|
||||
sections={sections}
|
||||
layouts={layouts}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{sections.map((section) => (
|
||||
<Section
|
||||
key={section.id}
|
||||
section={section}
|
||||
dashboardId={dashboardId}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderContent()}
|
||||
{isEditable ? (
|
||||
<AddSectionControl
|
||||
sections={sections}
|
||||
layouts={layouts}
|
||||
dashboardId={dashboardId}
|
||||
isSectioned={isSectioned}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default GridCardLayoutV2;
|
||||
@@ -0,0 +1,63 @@
|
||||
.dashboard-breadcrumbs {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
max-width: 80%;
|
||||
|
||||
.dashboard-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
padding: 0px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.dashboard-btn:hover {
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.id-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0px 2px;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
|
||||
color: var(--bg-robin-400);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
height: 20px;
|
||||
|
||||
max-width: calc(100% - 120px);
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
}
|
||||
.id-btn:hover {
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 10%, transparent);
|
||||
color: var(--bg-robin-300);
|
||||
}
|
||||
|
||||
.dashboard-icon-image {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import getSessionStorageApi from 'api/browser/sessionstorage/get';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY } from 'hooks/dashboard/useDashboardsListQueryParams';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { LayoutGrid } from '@signozhq/icons';
|
||||
|
||||
import { Base64Icons } from '../../../DashboardContainer/DashboardSettings/General/utils';
|
||||
|
||||
import './DashboardBreadcrumbs.styles.scss';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
function DashboardBreadcrumbs({ title, image }: Props): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const goToListPage = useCallback(() => {
|
||||
const dashboardsListQueryParamsString = getSessionStorageApi(
|
||||
DASHBOARDS_LIST_QUERY_PARAMS_STORAGE_KEY,
|
||||
);
|
||||
|
||||
if (dashboardsListQueryParamsString) {
|
||||
safeNavigate({
|
||||
pathname: ROUTES.ALL_DASHBOARD,
|
||||
search: `?${dashboardsListQueryParamsString}`,
|
||||
});
|
||||
} else {
|
||||
safeNavigate(ROUTES.ALL_DASHBOARD);
|
||||
}
|
||||
}, [safeNavigate]);
|
||||
|
||||
return (
|
||||
<div className="dashboard-breadcrumbs">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<LayoutGrid size={14} />}
|
||||
className="dashboard-btn"
|
||||
onClick={goToListPage}
|
||||
>
|
||||
Dashboard /
|
||||
</Button>
|
||||
<Button type="text" className="id-btn dashboard-name-btn">
|
||||
<img
|
||||
src={image || Base64Icons[0]}
|
||||
alt="dashboard-icon"
|
||||
className="dashboard-icon-image"
|
||||
/>
|
||||
{title}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardBreadcrumbs;
|
||||
@@ -0,0 +1,9 @@
|
||||
.dashboard-header {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { memo } from 'react';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
|
||||
import DashboardBreadcrumbs from './DashboardBreadcrumbs';
|
||||
|
||||
import './DashboardHeader.styles.scss';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
function DashboardHeader({ title, image }: Props): JSX.Element {
|
||||
return (
|
||||
<div className="dashboard-header">
|
||||
<DashboardBreadcrumbs title={title} image={image} />
|
||||
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DashboardHeader);
|
||||
46
frontend/src/container/DashboardContainerV2/index.tsx
Normal file
46
frontend/src/container/DashboardContainerV2/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { FullScreen, useFullScreenHandle } from 'react-full-screen';
|
||||
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import DashboardDescriptionV2 from './DashboardDescriptionV2';
|
||||
import GridCardLayoutV2 from './GridCardLayoutV2';
|
||||
import type { V2Dashboard } from './utils';
|
||||
import styles from './DashboardContainerV2.module.scss';
|
||||
|
||||
interface Props {
|
||||
dashboard: V2Dashboard | undefined;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
function DashboardContainerV2({ dashboard, onRefetch }: Props): JSX.Element {
|
||||
const fullScreenHandle = useFullScreenHandle();
|
||||
const spec = dashboard?.spec;
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
|
||||
const isEditable = !dashboard?.locked && editDashboard;
|
||||
|
||||
return (
|
||||
<FullScreen handle={fullScreenHandle}>
|
||||
<div className={styles.container}>
|
||||
<DashboardDescriptionV2
|
||||
dashboard={dashboard}
|
||||
handle={fullScreenHandle}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
<div className={styles.body}>
|
||||
<GridCardLayoutV2
|
||||
layouts={spec?.layouts}
|
||||
panels={spec?.panels ?? undefined}
|
||||
dashboardId={dashboard?.id}
|
||||
isEditable={isEditable}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FullScreen>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardContainerV2;
|
||||
189
frontend/src/container/DashboardContainerV2/patchOps.ts
Normal file
189
frontend/src/container/DashboardContainerV2/patchOps.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import type {
|
||||
DashboardGridItemDTO,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
DashboardtypesLayoutDTO,
|
||||
DashboardtypesPanelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DashboardtypesJSONPatchOperationDTOOp } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { GridItemV2 } from './utils';
|
||||
|
||||
/**
|
||||
* Pure RFC-6902 JSON-Patch builders for the V2 dashboard spec. These are
|
||||
* intentionally side-effect-free (no React, no network) so they can be unit
|
||||
* tested and reused by the layout hooks. JSON pointers target the postable
|
||||
* shape: `/spec/layouts/...`, `/spec/panels/...` (matches the existing V2
|
||||
* patches in DashboardSettings/General and DashboardDescriptionV2).
|
||||
*/
|
||||
|
||||
const { add, replace, remove } = DashboardtypesJSONPatchOperationDTOOp;
|
||||
|
||||
const PANEL_REF_PREFIX = '#/spec/panels/';
|
||||
|
||||
export function panelRef(panelId: string): string {
|
||||
return `${PANEL_REF_PREFIX}${panelId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a minimal, backend-valid panel for a given plugin kind. The spec
|
||||
* requires exactly one query whose plugin kind is allowed for the panel;
|
||||
* `signoz/BuilderQuery` is allowed for every panel kind and its contents are not
|
||||
* validated, so an empty builder query is the safe default. The real query is
|
||||
* filled in once the panel editor lands.
|
||||
*/
|
||||
export function createDefaultPanel(pluginKind: string): DashboardtypesPanelDTO {
|
||||
// The DTO types plugin/query kinds as large generated enum unions; the kind
|
||||
// here is chosen dynamically by the user, so we build the structurally-valid
|
||||
// shape and assert the type.
|
||||
return {
|
||||
kind: 'Panel',
|
||||
spec: {
|
||||
display: { name: 'New panel' },
|
||||
plugin: { kind: pluginKind, spec: {} },
|
||||
queries: [
|
||||
{
|
||||
kind: 'TimeSeriesQuery',
|
||||
spec: { plugin: { kind: 'signoz/BuilderQuery', spec: { name: 'A' } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
}
|
||||
|
||||
/** Converts a UI grid item back into the spec's grid-item DTO shape. */
|
||||
export function gridItemToDTO(item: GridItemV2): DashboardGridItemDTO {
|
||||
return {
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
content: { $ref: panelRef(item.id) },
|
||||
};
|
||||
}
|
||||
|
||||
/** Replace the entire items array of one section (used on panel move/resize). */
|
||||
export function replaceSectionItemsOp(
|
||||
layoutIndex: number,
|
||||
items: GridItemV2[],
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return {
|
||||
op: replace,
|
||||
path: `/spec/layouts/${layoutIndex}/spec/items`,
|
||||
value: items.map(gridItemToDTO),
|
||||
};
|
||||
}
|
||||
|
||||
/** Replace the whole layouts array (used on section reorder — avoids move-index ambiguity). */
|
||||
export function reorderLayoutsOp(
|
||||
layouts: DashboardtypesLayoutDTO[],
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return { op: replace, path: '/spec/layouts', value: layouts };
|
||||
}
|
||||
|
||||
/** An empty titled Grid layout (one section). */
|
||||
export function newGridLayout(title: string): DashboardtypesLayoutDTO {
|
||||
return {
|
||||
kind: 'Grid' as DashboardtypesLayoutDTO['kind'],
|
||||
spec: { display: { title, collapse: { open: true } }, items: [] },
|
||||
};
|
||||
}
|
||||
|
||||
/** Append a new, empty titled Grid section. */
|
||||
export function addSectionOp(
|
||||
title: string,
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return { op: add, path: '/spec/layouts/-', value: newGridLayout(title) };
|
||||
}
|
||||
|
||||
interface AddPanelToSectionArgs {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
layoutIndex: number;
|
||||
item: DashboardGridItemDTO;
|
||||
}
|
||||
|
||||
/** Add a panel to `spec.panels` and an item ref into a section, as one atomic patch. */
|
||||
export function addPanelToSectionOps({
|
||||
panelId,
|
||||
panel,
|
||||
layoutIndex,
|
||||
item,
|
||||
}: AddPanelToSectionArgs): DashboardtypesJSONPatchOperationDTO[] {
|
||||
return [
|
||||
{ op: add, path: `/spec/panels/${panelId}`, value: panel },
|
||||
{ op: add, path: `/spec/layouts/${layoutIndex}/spec/items/-`, value: item },
|
||||
];
|
||||
}
|
||||
|
||||
interface MovePanelArgs {
|
||||
sourceIndex: number;
|
||||
sourceItems: GridItemV2[];
|
||||
targetIndex: number;
|
||||
targetItems: GridItemV2[];
|
||||
}
|
||||
|
||||
/** Move a panel's item ref from one section to another (panel stays in spec.panels). */
|
||||
export function movePanelBetweenSectionsOps({
|
||||
sourceIndex,
|
||||
sourceItems,
|
||||
targetIndex,
|
||||
targetItems,
|
||||
}: MovePanelArgs): DashboardtypesJSONPatchOperationDTO[] {
|
||||
return [
|
||||
replaceSectionItemsOp(sourceIndex, sourceItems),
|
||||
replaceSectionItemsOp(targetIndex, targetItems),
|
||||
];
|
||||
}
|
||||
|
||||
/** Rename an existing section's title. */
|
||||
export function renameSectionOp(
|
||||
layoutIndex: number,
|
||||
title: string,
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return {
|
||||
op: replace,
|
||||
path: `/spec/layouts/${layoutIndex}/spec/display/title`,
|
||||
value: title,
|
||||
};
|
||||
}
|
||||
|
||||
/** Persist a section's collapse state. `add` safely replaces the collapse object if present. */
|
||||
export function setSectionCollapseOp(
|
||||
layoutIndex: number,
|
||||
open: boolean,
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return {
|
||||
op: add,
|
||||
path: `/spec/layouts/${layoutIndex}/spec/display/collapse`,
|
||||
value: { open },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* First-section migration: give an existing untitled (free-flowing) layout a
|
||||
* title, turning it into a section in place while preserving its panels.
|
||||
*/
|
||||
export function titleUntitledSectionOp(
|
||||
layoutIndex: number,
|
||||
title: string,
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return {
|
||||
op: add,
|
||||
path: `/spec/layouts/${layoutIndex}/spec/display`,
|
||||
value: { title, collapse: { open: true } },
|
||||
};
|
||||
}
|
||||
|
||||
/** Remove a section. Panel cleanup (orphaned refs) is handled by the caller. */
|
||||
export function removeSectionOp(
|
||||
layoutIndex: number,
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return { op: remove, path: `/spec/layouts/${layoutIndex}` };
|
||||
}
|
||||
|
||||
/** Remove a panel definition from `spec.panels`. */
|
||||
export function removePanelOp(
|
||||
panelId: string,
|
||||
): DashboardtypesJSONPatchOperationDTO {
|
||||
return { op: remove, path: `/spec/panels/${panelId}` };
|
||||
}
|
||||
157
frontend/src/container/DashboardContainerV2/utils.ts
Normal file
157
frontend/src/container/DashboardContainerV2/utils.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesLayoutDTO,
|
||||
DashboardtypesPanelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export type V2Dashboard = DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
export interface GridItemV2 {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
panel: DashboardtypesPanelDTO | undefined;
|
||||
}
|
||||
|
||||
const PANEL_REF_PREFIX = '#/spec/panels/';
|
||||
|
||||
export function extractPanelIdFromRef(ref: string | undefined): string | null {
|
||||
if (!ref) {
|
||||
return null;
|
||||
}
|
||||
if (!ref.startsWith(PANEL_REF_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
return ref.slice(PANEL_REF_PREFIX.length);
|
||||
}
|
||||
|
||||
export function flattenGridLayout(
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null,
|
||||
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined,
|
||||
): GridItemV2[] {
|
||||
if (!layouts?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: GridItemV2[] = [];
|
||||
layouts.forEach((layoutEnvelope) => {
|
||||
if (layoutEnvelope?.kind !== 'Grid') {
|
||||
return;
|
||||
}
|
||||
const gridItems = layoutEnvelope.spec?.items ?? [];
|
||||
gridItems.forEach((item) => {
|
||||
const id = extractPanelIdFromRef(item.content?.$ref);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
items.push({
|
||||
id,
|
||||
x: item.x ?? 0,
|
||||
y: item.y ?? 0,
|
||||
width: item.width ?? 6,
|
||||
height: item.height ?? 6,
|
||||
panel: panels?.[id],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* A section corresponds to one entry in `spec.layouts`. If the Grid has a
|
||||
* `display.title`, it renders with a collapsible header; otherwise it is a
|
||||
* "default" untitled section (visually just the grid).
|
||||
*/
|
||||
export interface DashboardSectionV2 {
|
||||
/**
|
||||
* Stable identity used for React keys and dnd-kit sortable item ids. Derived
|
||||
* from the section's content (its first panel ref) so it survives reordering
|
||||
* — unlike the positional `layoutIndex`. See `getSectionStableId`.
|
||||
*/
|
||||
id: string;
|
||||
/** Position of this section's Grid in `spec.layouts`. All JSON-Patch ops target by this. */
|
||||
layoutIndex: number;
|
||||
title: string | undefined;
|
||||
open: boolean;
|
||||
items: GridItemV2[];
|
||||
repeatVariable: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a stable id for a section from its content. Reordering sections changes
|
||||
* their `layoutIndex` but not their content, so keying off the first panel ref
|
||||
* keeps React component instances (and any local state) bound to the right
|
||||
* section across a reorder. Empty sections fall back to a positional id — they
|
||||
* are rarely reordered, and a future backend `id` on the layout spec is the
|
||||
* proper long-term fix.
|
||||
*/
|
||||
export function getSectionStableId(
|
||||
items: GridItemV2[],
|
||||
layoutIndex: number,
|
||||
): string {
|
||||
if (items.length > 0) {
|
||||
return `sec-${items[0].id}`;
|
||||
}
|
||||
return `sec-empty-${layoutIndex}`;
|
||||
}
|
||||
|
||||
export function layoutsToSections(
|
||||
layouts: DashboardtypesLayoutDTO[] | undefined | null,
|
||||
panels: Record<string, DashboardtypesPanelDTO | undefined> | undefined,
|
||||
): DashboardSectionV2[] {
|
||||
if (!layouts?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return layouts
|
||||
.map((layoutEnvelope, idx) => {
|
||||
if (layoutEnvelope?.kind !== 'Grid') {
|
||||
return null;
|
||||
}
|
||||
const spec = layoutEnvelope.spec;
|
||||
const items: GridItemV2[] = (spec?.items ?? [])
|
||||
.map((item) => {
|
||||
const id = extractPanelIdFromRef(item.content?.$ref);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
x: item.x ?? 0,
|
||||
y: item.y ?? 0,
|
||||
width: item.width ?? 6,
|
||||
height: item.height ?? 6,
|
||||
panel: panels?.[id],
|
||||
};
|
||||
})
|
||||
.filter((it): it is GridItemV2 => it !== null);
|
||||
|
||||
const title = spec?.display?.title;
|
||||
// `open` defaults to true when no collapse field is set (the section
|
||||
// is expanded by default).
|
||||
const open = spec?.display?.collapse?.open !== false;
|
||||
|
||||
return {
|
||||
id: getSectionStableId(items, idx),
|
||||
layoutIndex: idx,
|
||||
title,
|
||||
open,
|
||||
items,
|
||||
repeatVariable: spec?.repeatVariable,
|
||||
};
|
||||
})
|
||||
.filter((s): s is DashboardSectionV2 => s !== null);
|
||||
}
|
||||
|
||||
export function getPanelKindLabel(
|
||||
panel: DashboardtypesPanelDTO | undefined,
|
||||
): string {
|
||||
const kind = panel?.spec?.plugin?.kind;
|
||||
if (!kind) {
|
||||
return 'unknown';
|
||||
}
|
||||
return kind.replace(/^signoz\//, '');
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -121,11 +121,6 @@ function Hosts(): JSX.Element {
|
||||
[dotMetricsEnabled],
|
||||
);
|
||||
|
||||
const primaryFilterKeys = useMemo(
|
||||
() => [dotMetricsEnabled ? 'host.name' : 'host_name'],
|
||||
[dotMetricsEnabled],
|
||||
);
|
||||
|
||||
const controlListPrefix = !showFilters ? (
|
||||
<div className={styles.quickFiltersToggleContainer}>
|
||||
<Button
|
||||
@@ -188,7 +183,6 @@ function Hosts(): JSX.Element {
|
||||
getEntityName={hostGetEntityName}
|
||||
getInitialLogTracesFilters={getInitialLogTracesFilters}
|
||||
getInitialEventsFilters={hostInitialEventsFilter}
|
||||
primaryFilterKeys={primaryFilterKeys}
|
||||
metadataConfig={hostDetailsMetadataConfig}
|
||||
entityWidgetInfo={hostWidgetInfo}
|
||||
getEntityQueryPayload={getHostMetricsQueryPayload}
|
||||
|
||||
@@ -101,10 +101,6 @@ export interface K8sBaseDetailsProps<T> {
|
||||
getEntityName: (entity: T) => string;
|
||||
getInitialLogTracesFilters: (entity: T) => TagFilterItem[];
|
||||
getInitialEventsFilters: (entity: T) => TagFilterItem[];
|
||||
/**
|
||||
* @deprecated It's not needed anymore, remove in the next PR
|
||||
*/
|
||||
primaryFilterKeys: string[];
|
||||
metadataConfig: K8sDetailsMetadataConfig<T>[];
|
||||
entityWidgetInfo: {
|
||||
title: string;
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
k8sClusterGetEntityName,
|
||||
k8sClusterGetSelectedItemFilters,
|
||||
k8sClusterInitialEventsFilter,
|
||||
k8sClusterInitialFilters,
|
||||
k8sClusterInitialLogTracesFilter,
|
||||
} from './constants';
|
||||
import {
|
||||
@@ -106,7 +105,6 @@ function K8sClustersList({
|
||||
getEntityName={k8sClusterGetEntityName}
|
||||
getInitialLogTracesFilters={k8sClusterInitialLogTracesFilter}
|
||||
getInitialEventsFilters={k8sClusterInitialEventsFilter}
|
||||
primaryFilterKeys={k8sClusterInitialFilters}
|
||||
metadataConfig={k8sClusterDetailsMetadataConfig}
|
||||
entityWidgetInfo={clusterWidgetInfo}
|
||||
getEntityQueryPayload={getClusterMetricsQueryPayload}
|
||||
|
||||
@@ -33,8 +33,6 @@ export const k8sClusterGetSelectedItemFilters = (
|
||||
export const k8sClusterDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sClusterData>[] =
|
||||
[{ label: 'Cluster Name', getValue: (p): string => p.meta.k8s_cluster_name }];
|
||||
|
||||
export const k8sClusterInitialFilters = [QUERY_KEYS.K8S_CLUSTER_NAME];
|
||||
|
||||
export const k8sClusterInitialEventsFilter = (
|
||||
item: K8sClusterData,
|
||||
): ReturnType<typeof createFilterItem>[] => [
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
k8sDaemonSetGetEntityName,
|
||||
k8sDaemonSetGetSelectedItemFilters,
|
||||
k8sDaemonSetInitialEventsFilter,
|
||||
k8sDaemonSetInitialFilters,
|
||||
k8sDaemonSetInitialLogTracesFilter,
|
||||
} from './constants';
|
||||
import {
|
||||
@@ -106,7 +105,6 @@ function K8sDaemonSetsList({
|
||||
getEntityName={k8sDaemonSetGetEntityName}
|
||||
getInitialLogTracesFilters={k8sDaemonSetInitialLogTracesFilter}
|
||||
getInitialEventsFilters={k8sDaemonSetInitialEventsFilter}
|
||||
primaryFilterKeys={k8sDaemonSetInitialFilters}
|
||||
metadataConfig={k8sDaemonSetDetailsMetadataConfig}
|
||||
entityWidgetInfo={daemonSetWidgetInfo}
|
||||
getEntityQueryPayload={getDaemonSetMetricsQueryPayload}
|
||||
|
||||
@@ -46,11 +46,6 @@ export const k8sDaemonSetDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sDaem
|
||||
},
|
||||
];
|
||||
|
||||
export const k8sDaemonSetInitialFilters = [
|
||||
QUERY_KEYS.K8S_DAEMON_SET_NAME,
|
||||
QUERY_KEYS.K8S_NAMESPACE_NAME,
|
||||
];
|
||||
|
||||
export const k8sDaemonSetInitialEventsFilter = (
|
||||
item: K8sDaemonSetsData,
|
||||
): ReturnType<typeof createFilterItem>[] => [
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
k8sDeploymentGetEntityName,
|
||||
k8sDeploymentGetSelectedItemFilters,
|
||||
k8sDeploymentInitialEventsFilter,
|
||||
k8sDeploymentInitialFilters,
|
||||
k8sDeploymentInitialLogTracesFilter,
|
||||
} from './constants';
|
||||
import {
|
||||
@@ -106,7 +105,6 @@ function K8sDeploymentsList({
|
||||
getEntityName={k8sDeploymentGetEntityName}
|
||||
getInitialLogTracesFilters={k8sDeploymentInitialLogTracesFilter}
|
||||
getInitialEventsFilters={k8sDeploymentInitialEventsFilter}
|
||||
primaryFilterKeys={k8sDeploymentInitialFilters}
|
||||
metadataConfig={k8sDeploymentDetailsMetadataConfig}
|
||||
entityWidgetInfo={deploymentWidgetInfo}
|
||||
getEntityQueryPayload={getDeploymentMetricsQueryPayload}
|
||||
|
||||
@@ -46,11 +46,6 @@ export const k8sDeploymentDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sDep
|
||||
},
|
||||
];
|
||||
|
||||
export const k8sDeploymentInitialFilters = [
|
||||
QUERY_KEYS.K8S_DEPLOYMENT_NAME,
|
||||
QUERY_KEYS.K8S_NAMESPACE_NAME,
|
||||
];
|
||||
|
||||
export const k8sDeploymentInitialEventsFilter = (
|
||||
item: K8sDeploymentsData,
|
||||
): ReturnType<typeof createFilterItem>[] => [
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
k8sJobGetEntityName,
|
||||
k8sJobGetSelectedItemFilters,
|
||||
k8sJobInitialEventsFilter,
|
||||
k8sJobInitialFilters,
|
||||
k8sJobInitialLogTracesFilter,
|
||||
} from './constants';
|
||||
import {
|
||||
@@ -106,7 +105,6 @@ function K8sJobsList({
|
||||
getEntityName={k8sJobGetEntityName}
|
||||
getInitialLogTracesFilters={k8sJobInitialLogTracesFilter}
|
||||
getInitialEventsFilters={k8sJobInitialEventsFilter}
|
||||
primaryFilterKeys={k8sJobInitialFilters}
|
||||
metadataConfig={k8sJobDetailsMetadataConfig}
|
||||
entityWidgetInfo={jobWidgetInfo}
|
||||
getEntityQueryPayload={getJobMetricsQueryPayload}
|
||||
|
||||
@@ -46,11 +46,6 @@ export const k8sJobDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sJobsData>[
|
||||
},
|
||||
];
|
||||
|
||||
export const k8sJobInitialFilters = [
|
||||
QUERY_KEYS.K8S_JOB_NAME,
|
||||
QUERY_KEYS.K8S_NAMESPACE_NAME,
|
||||
];
|
||||
|
||||
export const k8sJobInitialEventsFilter = (
|
||||
item: K8sJobsData,
|
||||
): ReturnType<typeof createFilterItem>[] => [
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
k8sNamespaceGetEntityName,
|
||||
k8sNamespaceGetSelectedItemFilters,
|
||||
k8sNamespaceInitialEventsFilter,
|
||||
k8sNamespaceInitialFilters,
|
||||
k8sNamespaceInitialLogTracesFilter,
|
||||
namespaceWidgetInfo,
|
||||
} from './constants';
|
||||
@@ -106,7 +105,6 @@ function K8sNamespacesList({
|
||||
getEntityName={k8sNamespaceGetEntityName}
|
||||
getInitialLogTracesFilters={k8sNamespaceInitialLogTracesFilter}
|
||||
getInitialEventsFilters={k8sNamespaceInitialEventsFilter}
|
||||
primaryFilterKeys={k8sNamespaceInitialFilters}
|
||||
metadataConfig={k8sNamespaceDetailsMetadataConfig}
|
||||
entityWidgetInfo={namespaceWidgetInfo}
|
||||
getEntityQueryPayload={getNamespaceMetricsQueryPayload}
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
k8sNodeGetEntityName,
|
||||
k8sNodeGetSelectedItemFilters,
|
||||
k8sNodeInitialEventsFilter,
|
||||
k8sNodeInitialFilters,
|
||||
k8sNodeInitialLogTracesFilter,
|
||||
nodeWidgetInfo,
|
||||
} from './constants';
|
||||
@@ -106,7 +105,6 @@ function K8sNodesList({
|
||||
getEntityName={k8sNodeGetEntityName}
|
||||
getInitialLogTracesFilters={k8sNodeInitialLogTracesFilter}
|
||||
getInitialEventsFilters={k8sNodeInitialEventsFilter}
|
||||
primaryFilterKeys={k8sNodeInitialFilters}
|
||||
metadataConfig={k8sNodeDetailsMetadataConfig}
|
||||
entityWidgetInfo={nodeWidgetInfo}
|
||||
getEntityQueryPayload={getNodeMetricsQueryPayload}
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
k8sPodGetEntityName,
|
||||
k8sPodGetSelectedItemFilters,
|
||||
k8sPodInitialEventsFilter,
|
||||
k8sPodInitialFilters,
|
||||
k8sPodInitialLogTracesFilter,
|
||||
podWidgetInfo,
|
||||
} from './constants';
|
||||
@@ -106,7 +105,6 @@ function K8sPodsList({
|
||||
getEntityName={k8sPodGetEntityName}
|
||||
getInitialLogTracesFilters={k8sPodInitialLogTracesFilter}
|
||||
getInitialEventsFilters={k8sPodInitialEventsFilter}
|
||||
primaryFilterKeys={k8sPodInitialFilters}
|
||||
metadataConfig={k8sPodDetailsMetadataConfig}
|
||||
entityWidgetInfo={podWidgetInfo}
|
||||
getEntityQueryPayload={getPodMetricsQueryPayload}
|
||||
|
||||
@@ -42,12 +42,6 @@ export const k8sPodDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sPodsData>[
|
||||
{ label: 'Node', getValue: (p): string => p.meta.k8s_node_name },
|
||||
];
|
||||
|
||||
export const k8sPodInitialFilters = [
|
||||
QUERY_KEYS.K8S_POD_NAME,
|
||||
QUERY_KEYS.K8S_CLUSTER_NAME,
|
||||
QUERY_KEYS.K8S_NAMESPACE_NAME,
|
||||
];
|
||||
|
||||
export const k8sPodInitialEventsFilter = (
|
||||
pod: K8sPodsData,
|
||||
): ReturnType<typeof createFilterItem>[] => [
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
k8sStatefulSetGetEntityName,
|
||||
k8sStatefulSetGetSelectedItemFilters,
|
||||
k8sStatefulSetInitialEventsFilter,
|
||||
k8sStatefulSetInitialFilters,
|
||||
k8sStatefulSetInitialLogTracesFilter,
|
||||
statefulSetWidgetInfo,
|
||||
} from './constants';
|
||||
@@ -106,7 +105,6 @@ function K8sStatefulSetsList({
|
||||
getEntityName={k8sStatefulSetGetEntityName}
|
||||
getInitialLogTracesFilters={k8sStatefulSetInitialLogTracesFilter}
|
||||
getInitialEventsFilters={k8sStatefulSetInitialEventsFilter}
|
||||
primaryFilterKeys={k8sStatefulSetInitialFilters}
|
||||
metadataConfig={k8sStatefulSetDetailsMetadataConfig}
|
||||
entityWidgetInfo={statefulSetWidgetInfo}
|
||||
getEntityQueryPayload={getStatefulSetMetricsQueryPayload}
|
||||
|
||||
@@ -42,11 +42,6 @@ export const k8sStatefulSetDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sSt
|
||||
},
|
||||
];
|
||||
|
||||
export const k8sStatefulSetInitialFilters = [
|
||||
QUERY_KEYS.K8S_STATEFUL_SET_NAME,
|
||||
QUERY_KEYS.K8S_NAMESPACE_NAME,
|
||||
];
|
||||
|
||||
export const k8sStatefulSetInitialEventsFilter = (
|
||||
item: K8sStatefulSetsData,
|
||||
): ReturnType<typeof createFilterItem>[] => [
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
k8sVolumeGetEntityName,
|
||||
k8sVolumeGetSelectedItemFilters,
|
||||
k8sVolumeInitialEventsFilter,
|
||||
k8sVolumeInitialFilters,
|
||||
k8sVolumeInitialLogTracesFilter,
|
||||
volumeWidgetInfo,
|
||||
} from './constants';
|
||||
@@ -106,7 +105,6 @@ function K8sVolumesList({
|
||||
getEntityName={k8sVolumeGetEntityName}
|
||||
getInitialLogTracesFilters={k8sVolumeInitialLogTracesFilter}
|
||||
getInitialEventsFilters={k8sVolumeInitialEventsFilter}
|
||||
primaryFilterKeys={k8sVolumeInitialFilters}
|
||||
metadataConfig={k8sVolumeDetailsMetadataConfig}
|
||||
entityWidgetInfo={volumeWidgetInfo}
|
||||
getEntityQueryPayload={getVolumeMetricsQueryPayload}
|
||||
|
||||
@@ -46,11 +46,6 @@ export const k8sVolumeDetailsMetadataConfig: K8sDetailsMetadataConfig<K8sVolumes
|
||||
},
|
||||
];
|
||||
|
||||
export const k8sVolumeInitialFilters = [
|
||||
QUERY_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
|
||||
QUERY_KEYS.K8S_NAMESPACE_NAME,
|
||||
];
|
||||
|
||||
export const k8sVolumeInitialEventsFilter = (
|
||||
item: K8sVolumesData,
|
||||
): ReturnType<typeof createFilterItem>[] => [
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useLogsTableColumns } from 'components/Logs/TableView/useLogsTableColum
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import type { TanStackTableHandle } from 'components/TanStackTableView';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { useHiddenColumnIds } from 'components/TanStackTableView/useColumnStore';
|
||||
import { CARD_BODY_STYLE } from 'constants/card';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { OptionFormatTypes } from 'constants/optionsFormatTypes';
|
||||
@@ -24,13 +23,11 @@ import { QueryParams } from 'constants/query';
|
||||
import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
|
||||
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
||||
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useEventSource } from 'providers/EventSource';
|
||||
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
// interfaces
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
@@ -54,9 +51,6 @@ function LiveLogsList({
|
||||
const { isConnectionLoading } = useEventSource();
|
||||
|
||||
const { activeLogId } = useCopyLogLink();
|
||||
const { logs: logsPreferences } = usePreferenceContext();
|
||||
const hiddenColumnIds = useHiddenColumnIds(LOCALSTORAGE.LOGS_LIST_COLUMNS);
|
||||
const hasReconciledHiddenColumnsRef = useRef(false);
|
||||
|
||||
const {
|
||||
activeLog,
|
||||
@@ -72,7 +66,7 @@ function LiveLogsList({
|
||||
[logs],
|
||||
);
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: StringOperators.NOOP,
|
||||
@@ -83,16 +77,7 @@ function LiveLogsList({
|
||||
[formattedLogs, activeLogId],
|
||||
);
|
||||
|
||||
const selectedFields = convertKeysToColumnFields([
|
||||
...defaultLogsSelectedColumns,
|
||||
...options.selectColumns,
|
||||
]);
|
||||
|
||||
const syncedSelectedColumns = useMemo(
|
||||
() =>
|
||||
options.selectColumns.filter(({ name }) => !hiddenColumnIds.includes(name)),
|
||||
[options.selectColumns, hiddenColumnIds],
|
||||
);
|
||||
const selectedFields = convertKeysToColumnFields(options.selectColumns);
|
||||
|
||||
const logsColumns = useLogsTableColumns({
|
||||
fields: selectedFields,
|
||||
@@ -100,30 +85,6 @@ function LiveLogsList({
|
||||
appendTo: 'end',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasReconciledHiddenColumnsRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasReconciledHiddenColumnsRef.current = true;
|
||||
|
||||
if (syncedSelectedColumns.length === options.selectColumns.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
logsPreferences.updateColumns(syncedSelectedColumns);
|
||||
}, [logsPreferences, options.selectColumns.length, syncedSelectedColumns]);
|
||||
|
||||
const handleColumnRemove = useCallback(
|
||||
(columnId: string) => {
|
||||
const updatedColumns = options.selectColumns.filter(
|
||||
({ name }) => name !== columnId,
|
||||
);
|
||||
logsPreferences.updateColumns(updatedColumns);
|
||||
},
|
||||
[options.selectColumns, logsPreferences],
|
||||
);
|
||||
|
||||
const makeOnLogCopy = useCallback(
|
||||
(log: ILog) =>
|
||||
(event: MouseEvent<HTMLElement>): void => {
|
||||
@@ -237,7 +198,7 @@ function LiveLogsList({
|
||||
ref={ref as React.Ref<TanStackTableHandle>}
|
||||
columns={logsColumns}
|
||||
columnStorageKey={LOCALSTORAGE.LOGS_LIST_COLUMNS}
|
||||
onColumnRemove={handleColumnRemove}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
plainTextCellLineClamp={options.maxLines}
|
||||
cellTypographySize={options.fontSize}
|
||||
data={formattedLogs}
|
||||
|
||||
@@ -18,21 +18,19 @@ import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import Spinner from 'components/Spinner';
|
||||
import type { TanStackTableHandle } from 'components/TanStackTableView';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { useHiddenColumnIds } from 'components/TanStackTableView/useColumnStore';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { CARD_BODY_STYLE } from 'constants/card';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
||||
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
|
||||
import useScrollToLog from 'hooks/logs/useScrollToLog';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import APIError from 'types/api/error';
|
||||
// interfaces
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
@@ -69,10 +67,6 @@ function LogsExplorerList({
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { activeLogId } = useCopyLogLink();
|
||||
const { logs: logsPreferences } = usePreferenceContext();
|
||||
const hiddenColumnIds = useHiddenColumnIds(LOCALSTORAGE.LOGS_LIST_COLUMNS);
|
||||
const hasReconciledHiddenColumnsRef = useRef(false);
|
||||
|
||||
const {
|
||||
activeLog,
|
||||
onAddToQuery,
|
||||
@@ -81,7 +75,7 @@ function LogsExplorerList({
|
||||
handleCloseLogDetail,
|
||||
} = useLogDetailHandlers();
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator:
|
||||
@@ -97,28 +91,15 @@ function LogsExplorerList({
|
||||
);
|
||||
|
||||
const selectedFields = useMemo(
|
||||
() =>
|
||||
convertKeysToColumnFields([
|
||||
...defaultLogsSelectedColumns,
|
||||
...options.selectColumns,
|
||||
]),
|
||||
[options],
|
||||
() => convertKeysToColumnFields(options.selectColumns),
|
||||
[options.selectColumns],
|
||||
);
|
||||
|
||||
const syncedSelectedColumns = useMemo(
|
||||
() =>
|
||||
options.selectColumns.filter(({ name }) => !hiddenColumnIds.includes(name)),
|
||||
[options.selectColumns, hiddenColumnIds],
|
||||
);
|
||||
|
||||
const handleColumnRemove = useCallback(
|
||||
(columnId: string) => {
|
||||
const updatedColumns = options.selectColumns.filter(
|
||||
({ name }) => name !== columnId,
|
||||
);
|
||||
logsPreferences.updateColumns(updatedColumns);
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(cols: TableColumnDef<ILog>[]): void => {
|
||||
config?.addColumn?.onReorder(cols.map((c) => c.id));
|
||||
},
|
||||
[options.selectColumns, logsPreferences],
|
||||
[config],
|
||||
);
|
||||
|
||||
const logsColumns = useLogsTableColumns({
|
||||
@@ -161,20 +142,6 @@ function LogsExplorerList({
|
||||
}
|
||||
}, [isLoading, isFetching, isError, logs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasReconciledHiddenColumnsRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasReconciledHiddenColumnsRef.current = true;
|
||||
|
||||
if (syncedSelectedColumns.length === options.selectColumns.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
logsPreferences.updateColumns(syncedSelectedColumns);
|
||||
}, [logsPreferences, options.selectColumns.length, syncedSelectedColumns]);
|
||||
|
||||
const getItemContent = useCallback(
|
||||
(_: number, log: ILog): JSX.Element => {
|
||||
if (options.format === 'raw') {
|
||||
@@ -237,7 +204,8 @@ function LogsExplorerList({
|
||||
ref={ref as React.Ref<TanStackTableHandle>}
|
||||
columns={logsColumns}
|
||||
columnStorageKey={LOCALSTORAGE.LOGS_LIST_COLUMNS}
|
||||
onColumnRemove={handleColumnRemove}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
plainTextCellLineClamp={options.maxLines}
|
||||
cellTypographySize={options.fontSize}
|
||||
data={logs}
|
||||
|
||||
@@ -9,8 +9,6 @@ import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
import QueryStatus from './QueryStatus';
|
||||
|
||||
function LogsActionsContainer({
|
||||
listQuery,
|
||||
selectedPanelType,
|
||||
@@ -18,10 +16,6 @@ function LogsActionsContainer({
|
||||
handleToggleFrequencyChart,
|
||||
orderBy,
|
||||
setOrderBy,
|
||||
isFetching,
|
||||
isLoading,
|
||||
isError,
|
||||
isSuccess,
|
||||
}: {
|
||||
listQuery: any;
|
||||
selectedPanelType: PANEL_TYPES;
|
||||
@@ -29,10 +23,6 @@ function LogsActionsContainer({
|
||||
handleToggleFrequencyChart: () => void;
|
||||
orderBy: string;
|
||||
setOrderBy: (value: string) => void;
|
||||
isFetching: boolean;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isSuccess: boolean;
|
||||
}): JSX.Element {
|
||||
const { options, config } = useOptionsMenu({
|
||||
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
|
||||
@@ -106,17 +96,6 @@ function LogsActionsContainer({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(selectedPanelType === PANEL_TYPES.TIME_SERIES ||
|
||||
selectedPanelType === PANEL_TYPES.TABLE) && (
|
||||
<div className="query-stats">
|
||||
<QueryStatus
|
||||
loading={isLoading || isFetching}
|
||||
error={isError}
|
||||
success={isSuccess}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -155,40 +155,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.query-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
align-self: flex-end;
|
||||
|
||||
.rows {
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: 0.36px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
.time {
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: 0.36px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn {
|
||||
border: none;
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
.query-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { LoaderCircle, CircleCheck } from '@signozhq/icons';
|
||||
import { Spin } from 'antd';
|
||||
|
||||
import solidXCircleUrl from '@/assets/Icons/solid-x-circle.svg';
|
||||
|
||||
import './QueryStatus.styles.scss';
|
||||
|
||||
interface IQueryStatusProps {
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export default function QueryStatus(
|
||||
props: IQueryStatusProps,
|
||||
): React.ReactElement {
|
||||
const { loading, error, success } = props;
|
||||
|
||||
const content = useMemo((): React.ReactElement => {
|
||||
if (loading) {
|
||||
return (
|
||||
<Spin
|
||||
spinning
|
||||
size="small"
|
||||
indicator={<LoaderCircle className="animate-spin" size="md" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<img
|
||||
src={solidXCircleUrl}
|
||||
alt="header"
|
||||
className="error"
|
||||
style={{ height: '14px', width: '14px' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (success) {
|
||||
return (
|
||||
<CircleCheck className="success" size={14} fill={Color.BG_ROBIN_500} />
|
||||
);
|
||||
}
|
||||
return <div />;
|
||||
}, [error, loading, success]);
|
||||
return <div className="query-status">{content}</div>;
|
||||
}
|
||||
@@ -160,7 +160,7 @@ function LogsExplorerViewsContainer({
|
||||
'custom',
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching, isError, isSuccess, error } =
|
||||
const { data, isLoading, isFetching, isError, error } =
|
||||
useGetExplorerQueryRange(
|
||||
requestData,
|
||||
selectedPanelType,
|
||||
@@ -437,10 +437,6 @@ function LogsExplorerViewsContainer({
|
||||
handleToggleFrequencyChart={handleToggleFrequencyChart}
|
||||
orderBy={orderBy}
|
||||
setOrderBy={setOrderBy}
|
||||
isFetching={isFetching}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
isSuccess={isSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ function AllAttributes({
|
||||
);
|
||||
|
||||
const attributes = useMemo(
|
||||
() => attributesData?.data.attributes ?? [],
|
||||
() => attributesData?.data?.attributes ?? [],
|
||||
[attributesData],
|
||||
);
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ function MetricDetails({
|
||||
);
|
||||
|
||||
const metadata = useMemo(() => {
|
||||
if (!metricMetadataResponse) {
|
||||
if (!metricMetadataResponse?.data) {
|
||||
return null;
|
||||
}
|
||||
const { type, description, unit, temporality, isMonotonic } =
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
ensureLogsRequiredColumns,
|
||||
} from '../constants';
|
||||
|
||||
const TIMESTAMP = defaultLogsSelectedColumns.find(
|
||||
(c) => c.name === 'timestamp',
|
||||
);
|
||||
const BODY = defaultLogsSelectedColumns.find((c) => c.name === 'body');
|
||||
|
||||
if (!TIMESTAMP || !BODY) {
|
||||
throw new Error('defaults missing timestamp/body — test fixture invalid');
|
||||
}
|
||||
|
||||
const ATTR_A: TelemetryFieldKey = {
|
||||
name: 'service.name',
|
||||
signal: 'logs',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
};
|
||||
const ATTR_B: TelemetryFieldKey = {
|
||||
name: 'severity_text',
|
||||
signal: 'logs',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: 'string',
|
||||
};
|
||||
|
||||
describe('ensureLogsRequiredColumns', () => {
|
||||
it('prepends both timestamp + body to an empty list', () => {
|
||||
expect(ensureLogsRequiredColumns([])).toStrictEqual([TIMESTAMP, BODY]);
|
||||
});
|
||||
|
||||
it('prepends only `body` when `timestamp` is already present', () => {
|
||||
expect(ensureLogsRequiredColumns([TIMESTAMP, ATTR_A])).toStrictEqual([
|
||||
BODY,
|
||||
TIMESTAMP,
|
||||
ATTR_A,
|
||||
]);
|
||||
});
|
||||
|
||||
it('prepends only `timestamp` when `body` is already present', () => {
|
||||
expect(ensureLogsRequiredColumns([BODY, ATTR_A])).toStrictEqual([
|
||||
TIMESTAMP,
|
||||
BODY,
|
||||
ATTR_A,
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns the same array when both are present (no duplicates, original order preserved)', () => {
|
||||
const input = [TIMESTAMP, BODY, ATTR_A, ATTR_B];
|
||||
expect(ensureLogsRequiredColumns(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('preserves a non-default order when both are present', () => {
|
||||
const input = [ATTR_A, BODY, ATTR_B, TIMESTAMP];
|
||||
expect(ensureLogsRequiredColumns(input)).toStrictEqual(input);
|
||||
});
|
||||
|
||||
it('prepends both when neither is present in a list of user attributes', () => {
|
||||
expect(ensureLogsRequiredColumns([ATTR_A, ATTR_B])).toStrictEqual([
|
||||
TIMESTAMP,
|
||||
BODY,
|
||||
ATTR_A,
|
||||
ATTR_B,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not duplicate if a required column appears twice in the input', () => {
|
||||
// Tolerant of malformed input — invariant only adds *missing* required
|
||||
// columns; it does not deduplicate existing entries (that's a separate
|
||||
// concern, not its job).
|
||||
const input = [BODY, BODY, ATTR_A];
|
||||
const result = ensureLogsRequiredColumns(input);
|
||||
expect(result.filter((c) => c.name === 'timestamp')).toHaveLength(1);
|
||||
expect(result[0]).toStrictEqual(TIMESTAMP);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,32 @@ export const defaultLogsSelectedColumns: TelemetryFieldKey[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const LOGS_REQUIRED_COLUMNS = ['timestamp', 'body'] as const;
|
||||
|
||||
/**
|
||||
* Always-on invariant: every logs selectColumns array must contain `body` and
|
||||
* `timestamp`. Applied at both loader and writer boundaries so the picker, the
|
||||
* table, and persisted state can never diverge into a "missing required
|
||||
* column" state.
|
||||
*/
|
||||
export function ensureLogsRequiredColumns(
|
||||
columns: TelemetryFieldKey[],
|
||||
): TelemetryFieldKey[] {
|
||||
const missing = LOGS_REQUIRED_COLUMNS.filter(
|
||||
(name) => !columns.some((c) => c.name === name),
|
||||
);
|
||||
if (missing.length === 0) {
|
||||
return columns;
|
||||
}
|
||||
const defaultsByName = new Map(
|
||||
defaultLogsSelectedColumns.map((c) => [c.name, c]),
|
||||
);
|
||||
const prepended = missing
|
||||
.map((name) => defaultsByName.get(name))
|
||||
.filter((c): c is TelemetryFieldKey => c !== undefined);
|
||||
return [...prepended, ...columns];
|
||||
}
|
||||
|
||||
export const defaultTraceSelectedColumns: TelemetryFieldKey[] = [
|
||||
{
|
||||
name: 'service.name',
|
||||
|
||||
@@ -40,5 +40,6 @@ export type OptionsMenuConfig = {
|
||||
isFetching: boolean;
|
||||
value: TelemetryFieldKey[];
|
||||
onRemove: (key: string) => void;
|
||||
onReorder: (orderedIds: string[]) => void;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -187,30 +187,6 @@ const useOptionsMenu = ({
|
||||
searchedAttributesDataV5?.data.data.keys || {},
|
||||
).flat();
|
||||
if (searchedAttributesDataList.length) {
|
||||
if (dataSource === DataSource.LOGS) {
|
||||
const logsSelectedColumns: TelemetryFieldKey[] =
|
||||
defaultLogsSelectedColumns.map((e) => ({
|
||||
...e,
|
||||
name: e.name,
|
||||
signal: e.signal as SignalType,
|
||||
fieldContext: e.fieldContext as FieldContext,
|
||||
fieldDataType: e.fieldDataType as FieldDataType,
|
||||
}));
|
||||
return [
|
||||
...logsSelectedColumns,
|
||||
...searchedAttributesDataList
|
||||
.filter((attribute) => attribute.name !== 'body')
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
.map((e) => ({
|
||||
...e,
|
||||
name: e.name,
|
||||
signal: e.signal as SignalType,
|
||||
fieldContext: e.fieldContext as FieldContext,
|
||||
fieldDataType: e.fieldDataType as FieldDataType,
|
||||
})),
|
||||
];
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
return searchedAttributesDataList.map((e) => ({
|
||||
...e,
|
||||
name: e.name,
|
||||
@@ -297,24 +273,9 @@ const useOptionsMenu = ({
|
||||
return [...acc, column];
|
||||
}, [] as TelemetryFieldKey[]);
|
||||
|
||||
const optionsData: OptionsQuery = {
|
||||
...defaultOptionsQuery,
|
||||
selectColumns: newSelectedColumns,
|
||||
format: preferences?.formatting?.format || defaultOptionsQuery.format,
|
||||
maxLines: preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
|
||||
fontSize: preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
|
||||
};
|
||||
|
||||
updateColumns(newSelectedColumns);
|
||||
handleRedirectWithOptionsData(optionsData);
|
||||
},
|
||||
[
|
||||
searchedAttributeKeys,
|
||||
selectedColumnKeys,
|
||||
preferences,
|
||||
handleRedirectWithOptionsData,
|
||||
updateColumns,
|
||||
],
|
||||
[searchedAttributeKeys, selectedColumnKeys, preferences, updateColumns],
|
||||
);
|
||||
|
||||
const handleRemoveSelectedColumn = useCallback(
|
||||
@@ -327,27 +288,12 @@ const useOptionsMenu = ({
|
||||
notifications.error({
|
||||
message: 'There must be at least one selected column',
|
||||
});
|
||||
} else {
|
||||
const optionsData: OptionsQuery = {
|
||||
...defaultOptionsQuery,
|
||||
selectColumns: newSelectedColumns || [],
|
||||
format: preferences?.formatting?.format || defaultOptionsQuery.format,
|
||||
maxLines:
|
||||
preferences?.formatting?.maxLines || defaultOptionsQuery.maxLines,
|
||||
fontSize:
|
||||
preferences?.formatting?.fontSize || defaultOptionsQuery.fontSize,
|
||||
};
|
||||
updateColumns(newSelectedColumns || []);
|
||||
handleRedirectWithOptionsData(optionsData);
|
||||
return;
|
||||
}
|
||||
|
||||
updateColumns(newSelectedColumns || []);
|
||||
},
|
||||
[
|
||||
dataSource,
|
||||
notifications,
|
||||
preferences,
|
||||
handleRedirectWithOptionsData,
|
||||
updateColumns,
|
||||
],
|
||||
[dataSource, notifications, preferences, updateColumns],
|
||||
);
|
||||
|
||||
const handleFormatChange = useCallback(
|
||||
@@ -414,6 +360,18 @@ const useOptionsMenu = ({
|
||||
setSearchText(value);
|
||||
}, []);
|
||||
|
||||
const reorderSelectColumns = useCallback(
|
||||
(orderedIds: string[]): void => {
|
||||
const current = preferences?.columns ?? [];
|
||||
const byName = new Map(current.map((f) => [f.name, f]));
|
||||
const reordered = orderedIds
|
||||
.map((id) => byName.get(id))
|
||||
.filter((f): f is TelemetryFieldKey => f !== undefined);
|
||||
updateColumns(reordered);
|
||||
},
|
||||
[preferences, updateColumns],
|
||||
);
|
||||
|
||||
const handleFocus = (): void => {
|
||||
setIsFocused(true);
|
||||
};
|
||||
@@ -436,6 +394,7 @@ const useOptionsMenu = ({
|
||||
onSelect: handleSelectColumns,
|
||||
onRemove: handleRemoveSelectedColumn,
|
||||
onSearch: handleSearchAttribute,
|
||||
onReorder: reorderSelectColumns,
|
||||
},
|
||||
format: {
|
||||
value: preferences?.formatting?.format || defaultOptionsQuery.format,
|
||||
@@ -457,6 +416,7 @@ const useOptionsMenu = ({
|
||||
handleSelectColumns,
|
||||
handleRemoveSelectedColumn,
|
||||
handleSearchAttribute,
|
||||
reorderSelectColumns,
|
||||
handleFormatChange,
|
||||
handleMaxLinesChange,
|
||||
handleFontSizeChange,
|
||||
|
||||
@@ -9,6 +9,19 @@
|
||||
width: 0.2rem;
|
||||
height: 0.2rem;
|
||||
}
|
||||
|
||||
.option-value {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.option-meta-data-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.option-renderer-tooltip {
|
||||
|
||||
@@ -24,7 +24,7 @@ export const StyledCheckOutlined = styled(Check)`
|
||||
|
||||
export const TagContainer = styled(Badge)`
|
||||
&&& {
|
||||
display: inline-block;
|
||||
display: flex;
|
||||
border-radius: 3px;
|
||||
padding: 0.1rem 0.2rem;
|
||||
font-weight: 300;
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
color: var(--semantic-primary-foreground);
|
||||
|
||||
&--error {
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
|
||||
.reset-password-header-title {
|
||||
|
||||
67
frontend/src/container/ResetPassword/TokenError.tsx
Normal file
67
frontend/src/container/ResetPassword/TokenError.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { CircleAlert } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AuthError from 'components/AuthError/AuthError';
|
||||
import AuthPageContainer from 'components/AuthPageContainer';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import './ResetPassword.styles.scss';
|
||||
|
||||
interface TokenErrorContent {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
function getErrorContent(error?: APIError): TokenErrorContent {
|
||||
const code = error?.getErrorCode();
|
||||
|
||||
if (code === 'reset_password_token_expired') {
|
||||
return {
|
||||
title: 'Reset Password token is expired',
|
||||
subtitle:
|
||||
'Password reset links are single-use and expire after a set period. Please request a new password reset link.',
|
||||
};
|
||||
}
|
||||
|
||||
if (code === 'reset_password_token_not_found') {
|
||||
return {
|
||||
title: 'Invalid Reset Link',
|
||||
subtitle:
|
||||
'This reset password link is invalid or has already been used. Please request a new password reset link.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Reset Link Unavailable',
|
||||
subtitle:
|
||||
'We could not validate your reset password link. Please request a new one.',
|
||||
};
|
||||
}
|
||||
|
||||
interface TokenErrorProps {
|
||||
error?: APIError;
|
||||
}
|
||||
|
||||
function TokenError({ error }: TokenErrorProps): JSX.Element {
|
||||
const { title, subtitle } = getErrorContent(error);
|
||||
|
||||
return (
|
||||
<AuthPageContainer>
|
||||
<div className="reset-password-card reset-password-card--centered">
|
||||
<div className="reset-password-header">
|
||||
<div className="reset-password-header-icon reset-password-header-icon--error">
|
||||
<CircleAlert size={32} />
|
||||
</div>
|
||||
<Typography.Title level={4} className="reset-password-header-title">
|
||||
{title}
|
||||
</Typography.Title>
|
||||
<Typography.Text className="reset-password-header-subtitle">
|
||||
{subtitle}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{error && <AuthError error={error} />}
|
||||
</div>
|
||||
</AuthPageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenError;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user