mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-08 19:20:42 +01:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e2adf3329 | ||
|
|
04db70bade | ||
|
|
54aaad2cc3 | ||
|
|
c407456be7 | ||
|
|
49a0f7552e | ||
|
|
75cfe0b2c7 | ||
|
|
db3b7f7c34 | ||
|
|
bb62c9c060 |
51
.github/workflows/e2eci.yaml
vendored
51
.github/workflows/e2eci.yaml
vendored
@@ -40,6 +40,7 @@ jobs:
|
||||
matrix:
|
||||
project:
|
||||
- chromium
|
||||
shard: [1, 2, 3, 4]
|
||||
if: |
|
||||
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
|
||||
@@ -48,12 +49,33 @@ jobs:
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
# Images bring-up-stack would otherwise pull, at the tests/conftest.py
|
||||
# defaults. A stale tag only loses the head start.
|
||||
- name: pull-images
|
||||
id: pull-images
|
||||
background: true
|
||||
run: |
|
||||
printf '%s\n' \
|
||||
node:22-bookworm \
|
||||
golang:1.25-bookworm \
|
||||
python:3.13-slim \
|
||||
postgres:15 \
|
||||
clickhouse/clickhouse-server:25.12.5 \
|
||||
clickhouse/clickhouse-keeper:25.12.5 \
|
||||
signoz/signoz-schema-migrator:v0.144.9 \
|
||||
wiremock/wiremock:2.35.1-1 \
|
||||
| xargs -P 0 -n 1 docker pull -q
|
||||
- name: python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.13
|
||||
- name: uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
- name: python-install
|
||||
id: python-install
|
||||
background: true
|
||||
run: |
|
||||
cd tests && uv sync
|
||||
- name: node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -62,19 +84,9 @@ jobs:
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
- name: python-install
|
||||
run: |
|
||||
cd tests && uv sync
|
||||
- name: pnpm-install
|
||||
run: |
|
||||
cd tests/e2e && pnpm install --frozen-lockfile
|
||||
- name: playwright-browsers
|
||||
run: |
|
||||
docker create --name pw mcr.microsoft.com/playwright:v1.57.0-noble
|
||||
docker cp pw:/ms-playwright "$RUNNER_TEMP/ms-playwright"
|
||||
docker rm pw
|
||||
echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ms-playwright" >> "$GITHUB_ENV"
|
||||
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
|
||||
# Restore-only: the cacheci workflow owns cache saves. Seeds the
|
||||
# BuildKit cache mounts so the in-test image build is incremental.
|
||||
- name: restore
|
||||
@@ -99,6 +111,19 @@ jobs:
|
||||
tar -xf /restored/pnpm-store.tar -C /pnpm/store
|
||||
EOF
|
||||
docker build -f "$RUNNER_TEMP/inject.Dockerfile" "$RUNNER_TEMP/cacheci"
|
||||
- name: wait-stack-deps
|
||||
wait: [pull-images, python-install]
|
||||
# Started after the pulls so its download overlaps the CPU-bound
|
||||
# image build instead of competing for the network.
|
||||
- name: playwright-browsers
|
||||
id: playwright-browsers
|
||||
background: true
|
||||
run: |
|
||||
docker create --name pw mcr.microsoft.com/playwright:v1.57.0-noble
|
||||
docker cp pw:/ms-playwright "$RUNNER_TEMP/ms-playwright"
|
||||
docker rm pw
|
||||
echo "PLAYWRIGHT_BROWSERS_PATH=$RUNNER_TEMP/ms-playwright" >> "$GITHUB_ENV"
|
||||
cd tests/e2e && pnpm playwright install-deps ${{ matrix.project }}
|
||||
- name: bring-up-stack
|
||||
run: |
|
||||
cd tests && \
|
||||
@@ -106,10 +131,12 @@ jobs:
|
||||
--basetemp=./tmp/ \
|
||||
-vv --reuse --with-web \
|
||||
e2e/bootstrap/setup.py::test_setup
|
||||
- name: wait-playwright-browsers
|
||||
wait: playwright-browsers
|
||||
- name: playwright-test
|
||||
run: |
|
||||
cd tests/e2e && \
|
||||
pnpm playwright test --project=${{ matrix.project }}
|
||||
pnpm playwright test --project=${{ matrix.project }} --shard=${{ matrix.shard }}/4
|
||||
- name: teardown-stack
|
||||
if: always()
|
||||
run: |
|
||||
@@ -122,6 +149,6 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-artifacts-${{ matrix.project }}
|
||||
name: playwright-artifacts-${{ matrix.project }}-${{ matrix.shard }}
|
||||
path: tests/e2e/artifacts/
|
||||
retention-days: 5
|
||||
|
||||
@@ -9878,6 +9878,19 @@ components:
|
||||
- totalErrorSpansCount
|
||||
- hasMissingSpans
|
||||
type: object
|
||||
SpantypesGettableTraceThread:
|
||||
properties:
|
||||
nextCursor:
|
||||
type: string
|
||||
prevCursor:
|
||||
type: string
|
||||
spans:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesThreadSpan'
|
||||
type: array
|
||||
required:
|
||||
- spans
|
||||
type: object
|
||||
SpantypesGettableWaterfallTrace:
|
||||
properties:
|
||||
endTimestampMillis:
|
||||
@@ -10192,6 +10205,61 @@ components:
|
||||
nullable: true
|
||||
type: object
|
||||
type: object
|
||||
SpantypesThreadSpan:
|
||||
properties:
|
||||
attributes:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
duration_nano:
|
||||
minimum: 0
|
||||
type: integer
|
||||
events:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesEvent'
|
||||
type: array
|
||||
has_error:
|
||||
type: boolean
|
||||
kind_string:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
parent_span_id:
|
||||
type: string
|
||||
references:
|
||||
items:
|
||||
$ref: '#/components/schemas/SpantypesOtelSpanRef'
|
||||
type: array
|
||||
resource:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
span_id:
|
||||
type: string
|
||||
status_code_string:
|
||||
type: string
|
||||
status_message:
|
||||
type: string
|
||||
time_unix:
|
||||
minimum: 0
|
||||
type: integer
|
||||
trace_id:
|
||||
type: string
|
||||
required:
|
||||
- span_id
|
||||
- trace_id
|
||||
- parent_span_id
|
||||
- name
|
||||
- kind_string
|
||||
- time_unix
|
||||
- duration_nano
|
||||
- has_error
|
||||
- status_code_string
|
||||
- status_message
|
||||
- resource
|
||||
- attributes
|
||||
- events
|
||||
- references
|
||||
type: object
|
||||
SpantypesTraceAISummary:
|
||||
properties:
|
||||
tokens:
|
||||
@@ -16009,6 +16077,103 @@ paths:
|
||||
tags:
|
||||
- tracedetail
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/traces/{traceID}/thread:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the spans carrying gen_ai input or output messages in timestamp
|
||||
order. Pass nextCursor as after or prevCursor as before to page, or spanId
|
||||
to open the page around a span.
|
||||
operationId: GetTraceThread
|
||||
parameters:
|
||||
- description: Page size, at most 100. 0 means 20.
|
||||
in: query
|
||||
name: limit
|
||||
schema:
|
||||
description: Page size, at most 100. 0 means 20.
|
||||
type: integer
|
||||
- description: The nextCursor of a page; returns the spans after it. Set only
|
||||
one of after, before and spanId.
|
||||
in: query
|
||||
name: after
|
||||
schema:
|
||||
description: The nextCursor of a page; returns the spans after it. Set only
|
||||
one of after, before and spanId.
|
||||
type: string
|
||||
- description: The prevCursor of a page; returns the spans before it. Set only
|
||||
one of after, before and spanId.
|
||||
in: query
|
||||
name: before
|
||||
schema:
|
||||
description: The prevCursor of a page; returns the spans before it. Set
|
||||
only one of after, before and spanId.
|
||||
type: string
|
||||
- description: Returns the page around this span. Set only one of after, before
|
||||
and spanId.
|
||||
in: query
|
||||
name: spanId
|
||||
schema:
|
||||
description: Returns the page around this span. Set only one of after, before
|
||||
and spanId.
|
||||
type: string
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableTraceThread'
|
||||
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 thread view for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
x-signoz-stability: alpha
|
||||
/api/v1/user/me:
|
||||
get:
|
||||
deprecated: true
|
||||
|
||||
@@ -11426,6 +11426,86 @@ export interface SpantypesOtelSpanRefDTO {
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
export type SpantypesThreadSpanDTOAttributes = { [key: string]: unknown };
|
||||
|
||||
export type SpantypesThreadSpanDTOResource = { [key: string]: string };
|
||||
|
||||
export interface SpantypesThreadSpanDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
attributes: SpantypesThreadSpanDTOAttributes;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
duration_nano: number;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
events: SpantypesEventDTO[];
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
has_error: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
kind_string: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
parent_span_id: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
references: SpantypesOtelSpanRefDTO[];
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
resource: SpantypesThreadSpanDTOResource;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
span_id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_code_string: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status_message: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
time_unix: number;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
trace_id: string;
|
||||
}
|
||||
|
||||
export interface SpantypesGettableTraceThreadDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
nextCursor?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
prevCursor?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
spans: SpantypesThreadSpanDTO[];
|
||||
}
|
||||
|
||||
export type SpantypesWaterfallSpanDTOAttributesAnyOf = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
@@ -13110,6 +13190,40 @@ export type GetTraceSummary200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetTraceThreadPathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetTraceThreadParams = {
|
||||
/**
|
||||
* @type integer
|
||||
* @description Page size, at most 100. 0 means 20.
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type string
|
||||
* @description The nextCursor of a page; returns the spans after it. Set only one of after, before and spanId.
|
||||
*/
|
||||
after?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description The prevCursor of a page; returns the spans before it. Set only one of after, before and spanId.
|
||||
*/
|
||||
before?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Returns the page around this span. Set only one of after, before and spanId.
|
||||
*/
|
||||
spanId?: string;
|
||||
};
|
||||
|
||||
export type GetTraceThread200 = {
|
||||
data: SpantypesGettableTraceThreadDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUserPreferences200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -24,6 +24,9 @@ import type {
|
||||
GetTraceAggregationsPathParameters,
|
||||
GetTraceSummary200,
|
||||
GetTraceSummaryPathParameters,
|
||||
GetTraceThread200,
|
||||
GetTraceThreadParams,
|
||||
GetTraceThreadPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
@@ -257,6 +260,121 @@ export const invalidateGetTraceSummary = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the spans carrying gen_ai input or output messages in timestamp order. Pass nextCursor as after or prevCursor as before to page, or spanId to open the page around a span.
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
export const getTraceThread = (
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetTraceThread200>({
|
||||
url: `/api/v1/traces/${traceID}/thread`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetTraceThreadQueryKey = (
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
) => {
|
||||
return [
|
||||
`/api/v1/traces/${traceID}/thread`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getGetTraceThreadQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetTraceThreadQueryKey({ traceID }, params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getTraceThread>>> = ({
|
||||
signal,
|
||||
}) => getTraceThread({ traceID }, params, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: traceID !== null && traceID !== undefined,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetTraceThreadQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getTraceThread>>
|
||||
>;
|
||||
export type GetTraceThreadQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
|
||||
export function useGetTraceThread<
|
||||
TData = Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getTraceThread>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetTraceThreadQueryOptions(
|
||||
{ traceID },
|
||||
params,
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return withQueryKey(query, queryOptions.queryKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get thread view for a trace
|
||||
*/
|
||||
export const invalidateGetTraceThread = async (
|
||||
queryClient: QueryClient,
|
||||
{ traceID }: GetTraceThreadPathParameters,
|
||||
params?: GetTraceThreadParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetTraceThreadQueryKey({ traceID }, params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the flamegraph view of spans for a given trace ID.
|
||||
* @summary Get flamegraph view for a trace
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 16px;
|
||||
// OverlayScrollbar owns the scroll. A native scrollbar here changes the
|
||||
// body width when it toggles and shifts the header grid.
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -69,7 +72,7 @@
|
||||
}
|
||||
|
||||
.log-detail-drawer__content {
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.log-detail-drawer__log {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Drawer, Tooltip } from 'antd';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
@@ -288,148 +289,150 @@ function LogDetailInner({
|
||||
destroyOnClose
|
||||
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
|
||||
>
|
||||
<div
|
||||
className="log-detail-drawer__content"
|
||||
data-log-detail-ignore="true"
|
||||
data-testid="log-detail-drawer"
|
||||
>
|
||||
<div className="log-detail-drawer__log">
|
||||
<LogStateIndicator
|
||||
severityText={log.severity_text}
|
||||
severityNumber={log.severity_number}
|
||||
fontSize={options?.fontSize ?? FontSize.MEDIUM}
|
||||
/>
|
||||
<Tooltip
|
||||
title={removeEscapeCharacters(logBody)}
|
||||
placement="left"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
|
||||
</Tooltip>
|
||||
|
||||
<div className="log-overflow-shadow"> </div>
|
||||
</div>
|
||||
|
||||
<LogHighlights log={log} />
|
||||
|
||||
<div className="log-detail-drawer__section-divider" />
|
||||
|
||||
<div className="tabs-and-search">
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
className="views-tabs"
|
||||
onChange={handleModeChange}
|
||||
value={selectedView}
|
||||
items={[
|
||||
{
|
||||
value: VIEW_TYPES.OVERVIEW,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<Table size={14} />
|
||||
Overview
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: VIEW_TYPES.CONTEXT,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<TextSelect size={14} />
|
||||
Context
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: VIEW_TYPES.INFRAMETRICS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<Histogram size="md" />
|
||||
Metrics
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="log-detail-drawer__actions">
|
||||
{selectedView === VIEW_TYPES.CONTEXT && (
|
||||
<Tooltip
|
||||
title="Show Filters"
|
||||
placement="topLeft"
|
||||
aria-label="Show Filters"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Filter size="lg" />}
|
||||
onClick={handleFilterVisible}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isFilterVisible && contextQuery?.builder.queryData[0] && (
|
||||
<div className="log-detail-drawer-query-container">
|
||||
<QuerySearch
|
||||
onChange={(value): void => handleQueryExpressionChange(value, 0)}
|
||||
dataSource={DataSource.LOGS}
|
||||
queryData={contextQuery?.builder.queryData[0]}
|
||||
onRun={handleRunQuery}
|
||||
<OverlayScrollbar style={{ height: '100%' }}>
|
||||
<div
|
||||
className="log-detail-drawer__content"
|
||||
data-log-detail-ignore="true"
|
||||
data-testid="log-detail-drawer"
|
||||
>
|
||||
<div className="log-detail-drawer__log">
|
||||
<LogStateIndicator
|
||||
severityText={log.severity_text}
|
||||
severityNumber={log.severity_number}
|
||||
fontSize={options?.fontSize ?? FontSize.MEDIUM}
|
||||
/>
|
||||
<Tooltip
|
||||
title={removeEscapeCharacters(logBody)}
|
||||
placement="left"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
|
||||
</Tooltip>
|
||||
|
||||
<div className="log-overflow-shadow"> </div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.OVERVIEW && (
|
||||
<Overview
|
||||
logData={log}
|
||||
isListViewPanel={isListViewPanel}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
onApplyLogFilter={onApplyLogFilter}
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.CONTEXT && (
|
||||
<ContextView
|
||||
log={log}
|
||||
filters={filters}
|
||||
contextQuery={contextQuery}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.INFRAMETRICS && (
|
||||
<InfraMetrics
|
||||
clusterName={log.resources_string?.[RESOURCE_KEYS.CLUSTER_NAME] || ''}
|
||||
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
|
||||
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
|
||||
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
|
||||
timestamp={log.timestamp.toString()}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>
|
||||
)}
|
||||
<LogHighlights log={log} />
|
||||
|
||||
{selectedView === VIEW_TYPES.OVERVIEW && (
|
||||
<div className="log-detail-drawer__footer-hint">
|
||||
<div className="log-detail-drawer__footer-hint-content">
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
className="log-detail-drawer__footer-hint-text"
|
||||
>
|
||||
Use
|
||||
</Typography.Text>
|
||||
<ArrowUp size={14} className="log-detail-drawer__footer-hint-icon" />
|
||||
<span>/</span>
|
||||
<ArrowDown size={14} className="log-detail-drawer__footer-hint-icon" />
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
className="log-detail-drawer__footer-hint-text"
|
||||
>
|
||||
to view previous/next log
|
||||
</Typography.Text>
|
||||
<div className="log-detail-drawer__section-divider" />
|
||||
|
||||
<div className="tabs-and-search">
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
className="views-tabs"
|
||||
onChange={handleModeChange}
|
||||
value={selectedView}
|
||||
items={[
|
||||
{
|
||||
value: VIEW_TYPES.OVERVIEW,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<Table size={14} />
|
||||
Overview
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: VIEW_TYPES.CONTEXT,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<TextSelect size={14} />
|
||||
Context
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: VIEW_TYPES.INFRAMETRICS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<Histogram size="md" />
|
||||
Metrics
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="log-detail-drawer__actions">
|
||||
{selectedView === VIEW_TYPES.CONTEXT && (
|
||||
<Tooltip
|
||||
title="Show Filters"
|
||||
placement="topLeft"
|
||||
aria-label="Show Filters"
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Filter size="lg" />}
|
||||
onClick={handleFilterVisible}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isFilterVisible && contextQuery?.builder.queryData[0] && (
|
||||
<div className="log-detail-drawer-query-container">
|
||||
<QuerySearch
|
||||
onChange={(value): void => handleQueryExpressionChange(value, 0)}
|
||||
dataSource={DataSource.LOGS}
|
||||
queryData={contextQuery?.builder.queryData[0]}
|
||||
onRun={handleRunQuery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.OVERVIEW && (
|
||||
<Overview
|
||||
logData={log}
|
||||
isListViewPanel={isListViewPanel}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
onApplyLogFilter={onApplyLogFilter}
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.CONTEXT && (
|
||||
<ContextView
|
||||
log={log}
|
||||
filters={filters}
|
||||
contextQuery={contextQuery}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.INFRAMETRICS && (
|
||||
<InfraMetrics
|
||||
clusterName={log.resources_string?.[RESOURCE_KEYS.CLUSTER_NAME] || ''}
|
||||
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
|
||||
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
|
||||
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
|
||||
timestamp={log.timestamp.toString()}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.OVERVIEW && (
|
||||
<div className="log-detail-drawer__footer-hint">
|
||||
<div className="log-detail-drawer__footer-hint-content">
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
className="log-detail-drawer__footer-hint-text"
|
||||
>
|
||||
Use
|
||||
</Typography.Text>
|
||||
<ArrowUp size={14} className="log-detail-drawer__footer-hint-icon" />
|
||||
<span>/</span>
|
||||
<ArrowDown size={14} className="log-detail-drawer__footer-hint-icon" />
|
||||
<Typography.Text
|
||||
color="muted"
|
||||
className="log-detail-drawer__footer-hint-text"
|
||||
>
|
||||
to view previous/next log
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</OverlayScrollbar>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Select } from 'antd';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip/TextToolTip';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
@@ -30,6 +29,7 @@ import { capitalize, isEmpty } from 'lodash-es';
|
||||
import type { BaseSelectRef } from 'rc-select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import TruncatedTooltip from './TruncatedTooltip';
|
||||
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
|
||||
import {
|
||||
ALL_SELECTED_VALUE,
|
||||
@@ -729,65 +729,71 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
<TruncatedTooltip
|
||||
key={option.value || `option-${index}`}
|
||||
id={optionId}
|
||||
ref={(el): void => {
|
||||
if (index !== undefined) {
|
||||
optionRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className={cx('option-item', {
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onKeyDown={(e): void => {
|
||||
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleItemSelection();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(): void => {
|
||||
setActiveIndex(index ?? -1);
|
||||
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
title={String(option.label || '')}
|
||||
>
|
||||
<Checkbox
|
||||
value={isSelected}
|
||||
className="option-checkbox"
|
||||
onClick={(e): void => selectFromButton(e, 'checkbox')}
|
||||
>
|
||||
<div className="option-content">
|
||||
<Typography.Text truncate={1} className="option-label-text">
|
||||
{highlightMatchedText(String(option.label || ''), searchText)}
|
||||
</Typography.Text>
|
||||
{(option.type === 'custom' || option.type === 'regex') && (
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
{option.value && ensureValidOption(option.value) && (
|
||||
<Button
|
||||
type="text"
|
||||
className="only-btn"
|
||||
onClick={(e): void => selectFromButton(e, 'option')}
|
||||
>
|
||||
{currentToggleTagValue({ option: option.value })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
className="toggle-btn"
|
||||
{(textRef): JSX.Element => (
|
||||
<div
|
||||
id={optionId}
|
||||
ref={(el): void => {
|
||||
if (index !== undefined) {
|
||||
optionRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className={cx('option-item', {
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onKeyDown={(e): void => {
|
||||
if ((e.key === 'Enter' || e.key === SPACEKEY) && isActive) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleItemSelection();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(): void => {
|
||||
setActiveIndex(index ?? -1);
|
||||
setActiveChipIndex(-1); // Clear chip selection when hovering ALL option
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<Checkbox
|
||||
value={isSelected}
|
||||
className="option-checkbox"
|
||||
onClick={(e): void => selectFromButton(e, 'checkbox')}
|
||||
>
|
||||
Toggle
|
||||
</Button>
|
||||
<div className="option-content">
|
||||
<span ref={textRef} className="option-label-text">
|
||||
{highlightMatchedText(String(option.label || ''), searchText)}
|
||||
</span>
|
||||
{(option.type === 'custom' || option.type === 'regex') && (
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
{option.value && ensureValidOption(option.value) && (
|
||||
<Button
|
||||
type="text"
|
||||
className="only-btn"
|
||||
onClick={(e): void => selectFromButton(e, 'option')}
|
||||
>
|
||||
{currentToggleTagValue({ option: option.value })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
className="toggle-btn"
|
||||
onClick={(e): void => selectFromButton(e, 'checkbox')}
|
||||
>
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
</TruncatedTooltip>
|
||||
);
|
||||
},
|
||||
[
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { Select } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
@@ -25,6 +26,7 @@ import { capitalize, isEmpty } from 'lodash-es';
|
||||
import type { BaseSelectRef } from 'rc-select';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import TruncatedTooltip from './TruncatedTooltip';
|
||||
import { CustomSelectProps, OptionData } from './types';
|
||||
import {
|
||||
filterOptionsBySearch,
|
||||
@@ -207,41 +209,46 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
const optionId = `option-${index}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
id={optionId}
|
||||
ref={(el): void => {
|
||||
if (index !== undefined) {
|
||||
optionRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className={cx('option-item', {
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
handleSelection();
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === SPACEKEY) {
|
||||
e.preventDefault();
|
||||
handleSelection();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<div className="option-content">
|
||||
<div>{highlightMatchedText(String(option.label || ''), searchText)}</div>
|
||||
{option.type === 'custom' && (
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TruncatedTooltip key={option.value} title={String(option.label || '')}>
|
||||
{(textRef): JSX.Element => (
|
||||
<div
|
||||
id={optionId}
|
||||
ref={(el): void => {
|
||||
if (index !== undefined) {
|
||||
optionRefs.current[index] = el;
|
||||
}
|
||||
}}
|
||||
className={cx('option-item', {
|
||||
selected: isSelected,
|
||||
active: isActive,
|
||||
})}
|
||||
onClick={(e): void => {
|
||||
e.stopPropagation();
|
||||
handleSelection();
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === SPACEKEY) {
|
||||
e.preventDefault();
|
||||
handleSelection();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(): void => setActiveOptionIndex(index || -1)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
<div className="option-content">
|
||||
<span ref={textRef} className="option-label-text">
|
||||
{highlightMatchedText(String(option.label || ''), searchText)}
|
||||
</span>
|
||||
{option.type === 'custom' && (
|
||||
<div className="option-badge">{capitalize(option.type)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TruncatedTooltip>
|
||||
);
|
||||
},
|
||||
[highlightMatchedText, searchText, onChange, activeOptionIndex],
|
||||
@@ -702,50 +709,67 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
|
||||
// ===== Final Processing =====
|
||||
|
||||
// Apply highlight to matched text in options
|
||||
const optionsWithHighlight = useMemo(
|
||||
() =>
|
||||
options
|
||||
?.filter((option) =>
|
||||
String(option.label || '')
|
||||
.toLowerCase()
|
||||
.includes(searchText.toLowerCase()),
|
||||
)
|
||||
?.map((option) => ({
|
||||
...option,
|
||||
label: highlightMatchedText(String(option.label || ''), searchText),
|
||||
})),
|
||||
[options, searchText, highlightMatchedText],
|
||||
);
|
||||
const optionsWithHighlight = useMemo(() => {
|
||||
const matching =
|
||||
options?.filter((option) =>
|
||||
String(option.label || '')
|
||||
.toLowerCase()
|
||||
.includes(searchText.toLowerCase()),
|
||||
) ?? [];
|
||||
|
||||
// antd shows a value with no matching option as a bare string, so keep the
|
||||
// selected value listed to render it through the tooltip label below.
|
||||
return (
|
||||
isEmpty(value)
|
||||
? matching
|
||||
: prioritizeOrAddOptionForSingleSelect(matching, value)
|
||||
).map((option) => ({
|
||||
...option,
|
||||
// Rendered as the selected value in the closed control.
|
||||
label: (
|
||||
<TruncatedTooltip title={String(option.label || '')} side="bottom">
|
||||
{(textRef): JSX.Element => (
|
||||
<span ref={textRef} className="option-label-text">
|
||||
{highlightMatchedText(String(option.label || ''), searchText)}
|
||||
</span>
|
||||
)}
|
||||
</TruncatedTooltip>
|
||||
),
|
||||
}));
|
||||
}, [options, searchText, value, highlightMatchedText]);
|
||||
|
||||
// ===== Component Rendering =====
|
||||
return (
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-select', className)}
|
||||
placeholder={placeholder}
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
options={optionsWithHighlight}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={popupMatchSelectWidth}
|
||||
allowClear={allowClear ? { clearIcon } : false}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
|
||||
listHeight={300}
|
||||
placement={placement}
|
||||
optionFilterProp="label"
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...rest}
|
||||
/>
|
||||
// Self-provided so label tooltips work wherever this select is rendered.
|
||||
<TooltipProvider>
|
||||
<Select
|
||||
ref={selectRef}
|
||||
className={cx('custom-select', className)}
|
||||
placeholder={placeholder}
|
||||
showSearch
|
||||
filterOption={false}
|
||||
onSearch={handleSearch}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onDropdownVisibleChange={handleDropdownVisibleChange}
|
||||
open={isOpen}
|
||||
options={optionsWithHighlight}
|
||||
defaultActiveFirstOption={defaultActiveFirstOption}
|
||||
popupMatchSelectWidth={popupMatchSelectWidth}
|
||||
allowClear={allowClear ? { clearIcon } : false}
|
||||
getPopupContainer={getPopupContainer ?? popupContainer}
|
||||
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
|
||||
dropdownRender={customDropdownRender}
|
||||
menuItemSelectedIcon={null}
|
||||
popupClassName={cx('custom-select-dropdown-container', popupClassName)}
|
||||
listHeight={300}
|
||||
placement={placement}
|
||||
optionFilterProp="label"
|
||||
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...rest}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Above the antd select popup it is shown from.
|
||||
.tooltipContent {
|
||||
--tooltip-z-index: 2100;
|
||||
}
|
||||
41
frontend/src/components/NewSelect/TruncatedTooltip.tsx
Normal file
41
frontend/src/components/NewSelect/TruncatedTooltip.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ReactElement, RefObject, useRef, useState } from 'react';
|
||||
import { TooltipSimple, TooltipSimpleProps } from '@signozhq/ui/tooltip';
|
||||
|
||||
import styles from './TruncatedTooltip.module.scss';
|
||||
|
||||
interface TruncatedTooltipProps {
|
||||
title: string;
|
||||
side?: TooltipSimpleProps['side'];
|
||||
/** Renders the trigger; attach the ref to the element that may truncate. */
|
||||
children: (textRef: RefObject<HTMLSpanElement>) => ReactElement;
|
||||
}
|
||||
|
||||
function TruncatedTooltip({
|
||||
title,
|
||||
side = 'right',
|
||||
children,
|
||||
}: TruncatedTooltipProps): JSX.Element {
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleOpenChange = (next: boolean): void => {
|
||||
const el = textRef.current;
|
||||
setOpen(next && !!el && el.scrollWidth > el.clientWidth);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipSimple
|
||||
title={title}
|
||||
side={side}
|
||||
arrow
|
||||
delayDuration={300}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
tooltipContentProps={{ className: styles.tooltipContent }}
|
||||
>
|
||||
{children(textRef)}
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default TruncatedTooltip;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import CustomSelect from '../CustomSelect';
|
||||
|
||||
const LABEL = '/oteldemo.RecommendationService/ListRecommendations';
|
||||
|
||||
describe('CustomSelect selected value tooltip', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(400);
|
||||
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(200);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['listed in the options', [{ label: LABEL, value: LABEL }]],
|
||||
['missing from the options', [{ label: 'other', value: 'other' }]],
|
||||
])('reveals a truncated selected value %s on hover', async (_, options) => {
|
||||
render(<CustomSelect options={options} value={LABEL} />);
|
||||
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
await user.hover(screen.getByText(LABEL));
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(LABEL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
import TruncatedTooltip from '../TruncatedTooltip';
|
||||
|
||||
const LABEL = '/oteldemo.RecommendationService/ListRecommendations';
|
||||
|
||||
function renderRow(scrollWidth: number, clientWidth: number): void {
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
|
||||
.mockReturnValue(scrollWidth);
|
||||
jest
|
||||
.spyOn(HTMLElement.prototype, 'clientWidth', 'get')
|
||||
.mockReturnValue(clientWidth);
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<TruncatedTooltip title={LABEL}>
|
||||
{(textRef): JSX.Element => (
|
||||
<div data-testid="row">
|
||||
<span ref={textRef}>{LABEL}</span>
|
||||
</div>
|
||||
)}
|
||||
</TruncatedTooltip>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function hover(element: HTMLElement): Promise<void> {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
await user.hover(element);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
}
|
||||
|
||||
describe('TruncatedTooltip', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reveals truncated text when its row is hovered', async () => {
|
||||
renderRow(400, 200);
|
||||
|
||||
await hover(screen.getByTestId('row'));
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(LABEL);
|
||||
});
|
||||
|
||||
it('shows no tooltip when the text fits', async () => {
|
||||
renderRow(200, 200);
|
||||
|
||||
await hover(screen.getByTestId('row'));
|
||||
|
||||
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -135,6 +135,51 @@ const LONG_LABEL_OPTION = {
|
||||
value: 'checkout-payment-authorisation',
|
||||
};
|
||||
|
||||
/** Truncation: hovering a cut-off option reveals its full label beside the row. */
|
||||
export const TruncatedOption: Story = {
|
||||
args: { options: [LONG_LABEL_OPTION, ...options] },
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Service' }),
|
||||
);
|
||||
await userEvent.hover(
|
||||
await screen.findByRole('option', { name: LONG_LABEL_OPTION.label }),
|
||||
);
|
||||
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
|
||||
},
|
||||
};
|
||||
|
||||
/** Truncation: hovering a cut-off selected value reveals it in full. */
|
||||
export const TruncatedValue: Story = {
|
||||
args: {
|
||||
options: [LONG_LABEL_OPTION, ...options],
|
||||
value: LONG_LABEL_OPTION.value,
|
||||
},
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.hover(await screen.findByText(LONG_LABEL_OPTION.label));
|
||||
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
|
||||
},
|
||||
};
|
||||
|
||||
/** Truncation: a multi-select option reveals its full label the same way. */
|
||||
export const MultiTruncatedOption: Story = {
|
||||
render: (): JSX.Element => (
|
||||
<CustomMultiSelect
|
||||
aria-label="Services"
|
||||
options={[LONG_LABEL_OPTION, ...options]}
|
||||
/>
|
||||
),
|
||||
play: async (): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await screen.findByRole('combobox', { name: 'Services' }),
|
||||
);
|
||||
await userEvent.hover(
|
||||
await screen.findByRole('option', { name: LONG_LABEL_OPTION.label }),
|
||||
);
|
||||
await screen.findByRole('tooltip', { name: LONG_LABEL_OPTION.label });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Every tooltip the select renders, held open: the selected chip revealing the
|
||||
* option label it was cut from. Nothing bounds that label, so the chip is given
|
||||
|
||||
@@ -29,6 +29,13 @@ $custom-border-color: #2c3044;
|
||||
background-color: var(--l2-background);
|
||||
color: color-mix(in srgb, var(--border) 70%, transparent);
|
||||
}
|
||||
|
||||
// The search input overlays the selected value; lift the value above it while
|
||||
// closed so hovers reach its tooltip. Clicks still open the select and focus the input.
|
||||
&:not(.ant-select-open) .ant-select-selection-item {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep chip styles ONLY in the multi-select
|
||||
@@ -223,6 +230,33 @@ $custom-border-color: #2c3044;
|
||||
}
|
||||
}
|
||||
|
||||
// Ellipsized option label; also the selected value in the closed single select.
|
||||
.option-label-text {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// overscroll-behavior only holds on a box that overflows; a 1px sentinel past
|
||||
// the bottom makes even a short list overflow, so the wheel never scrolls the page.
|
||||
.custom-select-dropdown,
|
||||
.custom-multiselect-dropdown {
|
||||
position: relative;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom dropdown styles for single select
|
||||
.custom-select-dropdown {
|
||||
padding: 8px 0 0 0;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
InframonitoringtypesHostRecordDTO,
|
||||
InframonitoringtypesHostStatusDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
|
||||
import { K8sDetailsMetadataConfig } from 'container/InfraMonitoringK8sV2/Base/types';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { TextNoData } from 'container/InfraMonitoringK8sV2/components';
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
|
||||
// A block span leaves the icon on the text baseline, a couple of pixels
|
||||
// above the centre of the row it sits in
|
||||
[data-slot='icon'] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
|
||||
78
frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetails.stories.mocks.tsx
generated
Normal file
78
frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseDetails.stories.mocks.tsx
generated
Normal file
@@ -0,0 +1,78 @@
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { toggleControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
export const POD_UID = '63edbad0-398e-48e5-baba-f7ded7c73f1b';
|
||||
const POD_NAME = 'app-demo-loadgenerator-579fbdd749-q5kgd';
|
||||
export const NODE_NAME = 'gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-p6z7';
|
||||
|
||||
const POD_META = {
|
||||
'k8s.pod.uid': POD_UID,
|
||||
'k8s.pod.name': POD_NAME,
|
||||
'k8s.namespace.name': 'generator',
|
||||
'k8s.cluster.name': 'mgmt',
|
||||
'k8s.node.name': NODE_NAME,
|
||||
'k8s.deployment.name': 'app-demo-loadgenerator',
|
||||
};
|
||||
|
||||
const podRecord = {
|
||||
podUID: POD_UID,
|
||||
podCPU: 0.92,
|
||||
podCPURequest: 31,
|
||||
podCPULimit: 30.7,
|
||||
podMemory: 148 * 1024 * 1024,
|
||||
podMemoryRequest: 42,
|
||||
podMemoryLimit: 38,
|
||||
podStatus: 'running',
|
||||
podRestarts: 0,
|
||||
podAge: 49_000_000,
|
||||
meta: POD_META,
|
||||
};
|
||||
|
||||
const container = {
|
||||
containerName: 'loadgenerator',
|
||||
podName: POD_NAME,
|
||||
status: 'running',
|
||||
ready: true,
|
||||
restarts: 0,
|
||||
meta: POD_META,
|
||||
};
|
||||
|
||||
const list = (records: unknown[]): Record<string, unknown> => ({
|
||||
status: 'success',
|
||||
data: { type: 'list', records, total: records.length },
|
||||
});
|
||||
|
||||
export const drawerMocks = defineStoryMocks({
|
||||
controls: {
|
||||
openedFromANode: toggleControl('Opened from a node', {
|
||||
group: 'Drawer',
|
||||
value: true,
|
||||
}),
|
||||
},
|
||||
handlers: () => [
|
||||
rest.post('http://localhost/api/v2/infra_monitoring/pods', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(list([podRecord]))),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/infra_monitoring/kube_containers',
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(list([container]))),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/fields/values', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
|
||||
),
|
||||
),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
|
||||
import { podEntityConfig } from '../Pods/entity.config';
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
import K8sBaseDetails from './K8sBaseDetails';
|
||||
import {
|
||||
drawerMocks,
|
||||
NODE_NAME,
|
||||
POD_UID,
|
||||
} from './K8sBaseDetails.stories.mocks';
|
||||
import type { K8sBaseDetailsProps } from './types';
|
||||
import {
|
||||
getDrawerHistoryDepth,
|
||||
pushDrawerHistory,
|
||||
resetDrawerHistory,
|
||||
} from './useDrawerHistoryStore';
|
||||
|
||||
type DrawerArgs = PageStoryArgs<typeof drawerMocks> &
|
||||
K8sBaseDetailsProps<unknown>;
|
||||
|
||||
const route = `/infrastructure-monitoring/kubernetes?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=pods&${INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM}=${POD_UID}`;
|
||||
|
||||
const meta = {
|
||||
title: 'Infra monitoring/Kubernetes drawer',
|
||||
component: K8sBaseDetails as (props: DrawerArgs) => JSX.Element,
|
||||
...storyMocks(drawerMocks, { route }),
|
||||
decorators: [
|
||||
(Story, context): JSX.Element => {
|
||||
// The back control appears only for a resource opened from another
|
||||
const depth = getDrawerHistoryDepth();
|
||||
if (context.args.openedFromANode && depth === 0) {
|
||||
pushDrawerHistory({
|
||||
params: { selectedItem: NODE_NAME, category: InfraMonitoringEntity.NODES },
|
||||
category: InfraMonitoringEntity.NODES,
|
||||
label: NODE_NAME,
|
||||
entity: { nodeName: NODE_NAME },
|
||||
queryKeyPrefix: 'node',
|
||||
});
|
||||
}
|
||||
if (!context.args.openedFromANode && depth > 0) {
|
||||
resetDrawerHistory();
|
||||
}
|
||||
|
||||
return <Story />;
|
||||
},
|
||||
],
|
||||
} satisfies Meta<DrawerArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<DrawerArgs>;
|
||||
|
||||
/** A pod opened from a node's overview tab, so the header leads with the way back. */
|
||||
export const Default: Story = {
|
||||
args: { ...podEntityConfig.details } as Partial<DrawerArgs> as DrawerArgs,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { ArrowLeft, X } from '@signozhq/icons';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper, DrawerWrapperProps } from '@signozhq/ui/drawer';
|
||||
@@ -16,12 +16,19 @@ import {
|
||||
useGlobalTimeStore,
|
||||
} from 'store/globalTime';
|
||||
|
||||
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from '../constants';
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
K8S_CATEGORY_SINGULAR_LABELS,
|
||||
} from '../constants';
|
||||
import { useInfraMonitoringSelectedItemParams } from '../hooks';
|
||||
import CopyButton from 'periscope/components/CopyButton/CopyButton';
|
||||
import LoadingContainer from '../LoadingContainer';
|
||||
|
||||
import K8sBaseDetailsContent from './K8sBaseDetailsContent';
|
||||
import { CategoryIcon } from './categoryIcons';
|
||||
import { getEntityNameAttributeKey } from './relations';
|
||||
import { useDrawerHistoryStore } from './useDrawerHistoryStore';
|
||||
import { usePrimeEntityDetails } from './usePrimeEntityDetails';
|
||||
import { K8sBaseDetailsProps } from './types';
|
||||
import { useDrawerLifecycleStore } from './useDrawerLifecycleStore';
|
||||
|
||||
@@ -86,6 +93,11 @@ export default function K8sBaseDetails<T>({
|
||||
useInfraMonitoringSelectedItemParams();
|
||||
const selectedItem = selectedItemParams.selectedItem;
|
||||
|
||||
const selectedItemExpression = useMemo(
|
||||
() => getSelectedItemExpression(selectedItemParams),
|
||||
[getSelectedItemExpression, selectedItemParams],
|
||||
);
|
||||
|
||||
const entityQueryKey = useMemo(
|
||||
() =>
|
||||
getAutoRefreshQueryKey(
|
||||
@@ -121,9 +133,10 @@ export default function K8sBaseDetails<T>({
|
||||
const { minTime, maxTime } = getMinMaxTime();
|
||||
const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER);
|
||||
const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER);
|
||||
const expression = getSelectedItemExpression(selectedItemParams);
|
||||
|
||||
return fetchEntityData({ filter: { expression }, start, end }, signal);
|
||||
return fetchEntityData(
|
||||
{ filter: { expression: selectedItemExpression }, start, end },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
cacheTime: INFRA_MONITORING_DETAILS_CACHE_TIME,
|
||||
staleTime: INFRA_MONITORING_DETAILS_CACHE_TIME,
|
||||
@@ -158,9 +171,29 @@ export default function K8sBaseDetails<T>({
|
||||
}
|
||||
}, [selectedItem, markDrawerOpened, markDrawerClosed]);
|
||||
|
||||
const previousEntry = useDrawerHistoryStore(
|
||||
(store) => store.entries[store.entries.length - 1] ?? null,
|
||||
);
|
||||
const popDrawerHistory = useDrawerHistoryStore((store) => store.pop);
|
||||
const primeDetails = usePrimeEntityDetails();
|
||||
const resetHistory = useDrawerHistoryStore((store) => store.reset);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
resetHistory();
|
||||
setSelectedItemParams(null);
|
||||
}, [setSelectedItemParams]);
|
||||
}, [resetHistory, setSelectedItemParams]);
|
||||
|
||||
// Steps back through resources opened from an overview tab
|
||||
const handleBack = useCallback((): void => {
|
||||
const previous = popDrawerHistory();
|
||||
|
||||
if (!previous) {
|
||||
return;
|
||||
}
|
||||
|
||||
primeDetails(previous);
|
||||
setSelectedItemParams(previous.params);
|
||||
}, [popDrawerHistory, primeDetails, setSelectedItemParams]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean): void => {
|
||||
@@ -172,11 +205,25 @@ export default function K8sBaseDetails<T>({
|
||||
);
|
||||
|
||||
const handleCopyId = useCallback((): void => {
|
||||
toast.success('ID copied to clipboard', { position: 'bottom-left' });
|
||||
}, []);
|
||||
toast.success(
|
||||
`${K8S_CATEGORY_SINGULAR_LABELS[category]} name copied to clipboard`,
|
||||
{ position: 'bottom-left' },
|
||||
);
|
||||
}, [category]);
|
||||
|
||||
const entityName = entity ? getEntityName(entity) : '';
|
||||
|
||||
// meta carries the resource's parents; its own name comes from the config
|
||||
const entityAttributes = useMemo((): Record<string, string> => {
|
||||
const meta = (entity as { meta?: Record<string, string> | null } | null)
|
||||
?.meta;
|
||||
|
||||
return {
|
||||
...(meta ?? {}),
|
||||
...(entityName ? { [getEntityNameAttributeKey(category)]: entityName } : {}),
|
||||
};
|
||||
}, [entity, entityName, category]);
|
||||
|
||||
useEffect(() => {
|
||||
if (entity) {
|
||||
void logEvent(InfraMonitoringEvents.PageVisited, {
|
||||
@@ -187,20 +234,48 @@ export default function K8sBaseDetails<T>({
|
||||
}
|
||||
}, [entity, eventCategory]);
|
||||
|
||||
// Names the resource it returns to, so a drill several levels deep stays clear
|
||||
const backTarget = previousEntry
|
||||
? `Back to ${K8S_CATEGORY_SINGULAR_LABELS[previousEntry.category]}`
|
||||
: '';
|
||||
const backLabel = previousEntry ? `${backTarget}: ${previousEntry.label}` : '';
|
||||
|
||||
// TODO(H4ad): Improve this on component level
|
||||
// DrawerWrapper types `title` as string but renders any ReactNode.
|
||||
const drawerTitle = (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={handleClose}
|
||||
data-testid="close-drawer-button"
|
||||
className={styles.closeButton}
|
||||
prefix={<X />}
|
||||
/>
|
||||
{/* One control: step back where there is a trail, close otherwise.
|
||||
Clicking outside the drawer closes it either way. The label is text
|
||||
rather than a tooltip, which a click on the control would dismiss. */}
|
||||
{previousEntry ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={handleBack}
|
||||
data-testid="drawer-back-button"
|
||||
className={styles.backButton}
|
||||
aria-label={backLabel}
|
||||
prefix={<ArrowLeft size={14} />}
|
||||
>
|
||||
{backTarget}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={handleClose}
|
||||
data-testid="close-drawer-button"
|
||||
className={styles.closeButton}
|
||||
prefix={<X />}
|
||||
/>
|
||||
)}
|
||||
<Divider type="vertical" />
|
||||
<span className={styles.entityKind} data-testid="drawer-entity-kind">
|
||||
<CategoryIcon category={category} />
|
||||
{K8S_CATEGORY_SINGULAR_LABELS[category]}
|
||||
</span>
|
||||
<Typography.Text className={styles.title}>
|
||||
{entityName ||
|
||||
((isEntityError || hasResponseError) && 'Failed to load entity details') ||
|
||||
@@ -208,8 +283,8 @@ export default function K8sBaseDetails<T>({
|
||||
'-'}
|
||||
</Typography.Text>
|
||||
<CopyButton
|
||||
value={selectedItem ?? ''}
|
||||
ariaLabel="Copy ID"
|
||||
value={entityName || selectedItem || ''}
|
||||
ariaLabel={`Copy ${K8S_CATEGORY_SINGULAR_LABELS[category]} name`}
|
||||
className={styles.copyIdButton}
|
||||
testId="copy-id-button"
|
||||
onCopy={handleCopyId}
|
||||
@@ -271,6 +346,8 @@ export default function K8sBaseDetails<T>({
|
||||
customTabs={customTabs}
|
||||
logsAndTracesInitialExpression={logsAndTracesInitialExpression}
|
||||
eventsInitialExpression={eventsInitialExpression}
|
||||
entityAttributes={entityAttributes}
|
||||
entityName={entityName}
|
||||
/>
|
||||
</GlobalTimeProvider>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ChevronsLeftRight,
|
||||
Compass,
|
||||
DraftingCompass,
|
||||
LayoutGrid,
|
||||
ScrollText,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
@@ -31,6 +32,7 @@ import EntityEvents from '../EntityDetailsUtils/EntityEvents';
|
||||
import EntityLogs from '../EntityDetailsUtils/EntityLogs';
|
||||
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityLogs/hooks';
|
||||
import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
|
||||
import EntityOverview from '../EntityDetailsUtils/EntityOverview/EntityOverview';
|
||||
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
|
||||
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
|
||||
import {
|
||||
@@ -67,12 +69,16 @@ export default function K8sBaseDetailsContent<T>({
|
||||
customTabs,
|
||||
logsAndTracesInitialExpression,
|
||||
eventsInitialExpression,
|
||||
entityAttributes,
|
||||
entityName,
|
||||
}: K8sBaseDetailsContentProps<T>): JSX.Element {
|
||||
const { timeRange, selectedInterval, handleTimeChange } =
|
||||
useEntityDetailsTime();
|
||||
|
||||
const tabVisibility = useMemo(
|
||||
() => ({
|
||||
// Declared per category, since only a kubernetes resource relates to others
|
||||
showOverview: false,
|
||||
showMetrics: true,
|
||||
showLogs: true,
|
||||
showTraces: true,
|
||||
@@ -86,6 +92,9 @@ export default function K8sBaseDetailsContent<T>({
|
||||
|
||||
const validTabs = useMemo(() => {
|
||||
const tabs: string[] = [];
|
||||
if (tabVisibility.showOverview) {
|
||||
tabs.push(VIEW_TYPES.OVERVIEW);
|
||||
}
|
||||
if (tabVisibility.showMetrics) {
|
||||
tabs.push(VIEW_TYPES.METRICS);
|
||||
}
|
||||
@@ -104,14 +113,28 @@ export default function K8sBaseDetailsContent<T>({
|
||||
return tabs;
|
||||
}, [tabVisibility, customTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hideDetailViewTabs && !validTabs.includes(selectedView)) {
|
||||
const firstValid = validTabs[0] || VIEW_TYPES.METRICS;
|
||||
void setSelectedView(firstValid);
|
||||
}
|
||||
}, [hideDetailViewTabs, selectedView, validTabs, setSelectedView]);
|
||||
const defaultView = validTabs[0] || VIEW_TYPES.METRICS;
|
||||
|
||||
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hideDetailViewTabs &&
|
||||
selectedView &&
|
||||
!validTabs.includes(selectedView)
|
||||
) {
|
||||
void setSelectedView(defaultView);
|
||||
}
|
||||
}, [
|
||||
hideDetailViewTabs,
|
||||
selectedView,
|
||||
validTabs,
|
||||
defaultView,
|
||||
setSelectedView,
|
||||
]);
|
||||
|
||||
// An absent view param lands on the first tab the category offers
|
||||
const effectiveView = hideDetailViewTabs
|
||||
? VIEW_TYPES.METRICS
|
||||
: (selectedView ?? defaultView);
|
||||
|
||||
// Wait until the view is a valid tab so we don't log a pending value before
|
||||
// the validation effect above corrects an out-of-scope query param
|
||||
@@ -169,20 +192,20 @@ export default function K8sBaseDetailsContent<T>({
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
view: selectedView,
|
||||
view: effectiveView,
|
||||
});
|
||||
|
||||
logInfraExplorerNavigatedEvent({
|
||||
entityType: category,
|
||||
destination:
|
||||
selectedView === VIEW_TYPES.LOGS ? 'logs_explorer' : 'traces_explorer',
|
||||
effectiveView === VIEW_TYPES.LOGS ? 'logs_explorer' : 'traces_explorer',
|
||||
source: 'tab_cta_button',
|
||||
tab: selectedView,
|
||||
tab: effectiveView,
|
||||
sourceKey: null,
|
||||
drawerDurationMsAtNavigation: getDrawerDurationMs(),
|
||||
});
|
||||
|
||||
if (selectedView === VIEW_TYPES.LOGS) {
|
||||
if (effectiveView === VIEW_TYPES.LOGS) {
|
||||
const fullExpression = combineInitialAndUserExpression(
|
||||
logsAndTracesInitialExpression,
|
||||
userLogsExpression || '',
|
||||
@@ -207,7 +230,7 @@ export default function K8sBaseDetailsContent<T>({
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
|
||||
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
|
||||
} else if (selectedView === VIEW_TYPES.TRACES) {
|
||||
} else if (effectiveView === VIEW_TYPES.TRACES) {
|
||||
const fullExpression = combineInitialAndUserExpression(
|
||||
logsAndTracesInitialExpression,
|
||||
userTracesExpression || '',
|
||||
@@ -264,7 +287,7 @@ export default function K8sBaseDetailsContent<T>({
|
||||
filterExpression={getCountsFilterExpression(entity)}
|
||||
closeDrawer={handleClose}
|
||||
entityType={category}
|
||||
activeTab={selectedView}
|
||||
activeTab={effectiveView}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -275,8 +298,21 @@ export default function K8sBaseDetailsContent<T>({
|
||||
type="single"
|
||||
className={styles.viewsTabs}
|
||||
onChange={handleTabChange}
|
||||
value={selectedView}
|
||||
value={effectiveView}
|
||||
items={[
|
||||
...(tabVisibility.showOverview
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.OVERVIEW,
|
||||
label: (
|
||||
<div className={styles.viewTitle}>
|
||||
<LayoutGrid size={14} />
|
||||
Overview
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showMetrics
|
||||
? [
|
||||
{
|
||||
@@ -341,7 +377,7 @@ export default function K8sBaseDetailsContent<T>({
|
||||
]}
|
||||
/>
|
||||
|
||||
{selectedView === VIEW_TYPES.LOGS && (
|
||||
{effectiveView === VIEW_TYPES.LOGS && (
|
||||
<TooltipSimple title="Go to Logs Explorer" side="left" arrow>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -354,7 +390,7 @@ export default function K8sBaseDetailsContent<T>({
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.TRACES && (
|
||||
{effectiveView === VIEW_TYPES.TRACES && (
|
||||
<TooltipSimple title="Go to Traces Explorer" side="left" arrow>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -370,6 +406,16 @@ export default function K8sBaseDetailsContent<T>({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectiveView === VIEW_TYPES.OVERVIEW && tabVisibility.showOverview && (
|
||||
<EntityOverview
|
||||
category={category}
|
||||
eventEntity={eventCategory}
|
||||
attributes={entityAttributes}
|
||||
entity={entity}
|
||||
entityName={entityName}
|
||||
queryKeyPrefix={queryKeyPrefix}
|
||||
/>
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.METRICS && (
|
||||
<EntityMetrics<T>
|
||||
entity={entity}
|
||||
@@ -406,7 +452,7 @@ export default function K8sBaseDetailsContent<T>({
|
||||
/>
|
||||
)}
|
||||
{customTabs?.map((tab) =>
|
||||
selectedView === tab.key ? (
|
||||
effectiveView === tab.key ? (
|
||||
<Fragment key={tab.key}>
|
||||
{tab.render({
|
||||
entity,
|
||||
|
||||
@@ -39,6 +39,7 @@ import K8sOptionsSidePanel from './K8sOptionsSidePanel';
|
||||
import K8sHeader from './K8sHeader';
|
||||
import { K8sPaginationWarning } from './K8sPaginationWarning';
|
||||
import K8sTableToolbar from './K8sTableToolbar';
|
||||
import { resetDrawerHistory } from './useDrawerHistoryStore';
|
||||
import { K8sBaseFilters } from './types';
|
||||
import { getGroupedByMeta } from './utils';
|
||||
import { K8sInstrumentationChecksCallout } from './components/K8sInstrumentationChecksCallout/K8sInstrumentationChecksCallout';
|
||||
@@ -301,6 +302,8 @@ export function K8sBaseList<
|
||||
queryClient.setQueryData(detailQueryKey, { data: record });
|
||||
}
|
||||
|
||||
// Opening from the list starts a fresh trail for the drawer's back control
|
||||
resetDrawerHistory();
|
||||
setSelectedItemParams(params);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useInfraMonitoringCategory } from '../hooks';
|
||||
import {
|
||||
useInfraMonitoringCategory,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
} from '../hooks';
|
||||
import { getEntityConfig } from './entity.registry';
|
||||
import { K8sBaseList } from './K8sBaseList';
|
||||
import K8sBaseDetails from './K8sBaseDetails';
|
||||
@@ -15,27 +18,35 @@ export function K8sDynamicList({
|
||||
leftFilters,
|
||||
}: K8sDynamicListProps): JSX.Element | null {
|
||||
const [selectedCategory] = useInfraMonitoringCategory();
|
||||
const [selectedItemParams] = useInfraMonitoringSelectedItemParams();
|
||||
|
||||
const config = useMemo(
|
||||
() => getEntityConfig(selectedCategory),
|
||||
[selectedCategory],
|
||||
);
|
||||
|
||||
// A row opened from the drawer's overview tab belongs to another category
|
||||
const drawerCategory = selectedItemParams.category ?? selectedCategory;
|
||||
const drawerConfig = useMemo(
|
||||
() => getEntityConfig(drawerCategory),
|
||||
[drawerCategory],
|
||||
);
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { list, details } = config;
|
||||
|
||||
return (
|
||||
<>
|
||||
<K8sBaseList
|
||||
{...list}
|
||||
{...config.list}
|
||||
controlListPrefix={controlListPrefix}
|
||||
leftFilters={leftFilters}
|
||||
/>
|
||||
|
||||
<K8sBaseDetails {...details} />
|
||||
{/* No key: remounting would animate the drawer shut and open again
|
||||
for what is really a change of contents */}
|
||||
{drawerConfig && <K8sBaseDetails {...drawerConfig.details} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
useInfraMonitoringPageListing,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
} from '../hooks';
|
||||
import { resetDrawerHistory } from './useDrawerHistoryStore';
|
||||
import { K8sBaseFilters } from './types';
|
||||
import { useLogEventForColumnCustomized } from './useLogEventForColumnCustomized';
|
||||
import { useInfraMonitoringFontSize } from './useInfraMonitoringTablePreferencesStore';
|
||||
@@ -219,6 +220,8 @@ export function K8sExpandedRow<
|
||||
queryClient.setQueryData(detailQueryKey, { data: row });
|
||||
}
|
||||
|
||||
// Opening from the list starts a fresh trail for the drawer's back control
|
||||
resetDrawerHistory();
|
||||
setSelectedItemParams(params);
|
||||
},
|
||||
[
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import {
|
||||
NuqsTestingAdapter,
|
||||
type OnUrlUpdateFunction,
|
||||
} from 'nuqs/adapters/testing';
|
||||
import { act, render, waitFor } from 'tests/test-utils';
|
||||
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../../constants';
|
||||
import K8sBaseDetails from '../K8sBaseDetails';
|
||||
import { useDrawerHistoryStore } from '../useDrawerHistoryStore';
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="mock-datetime" />,
|
||||
}));
|
||||
|
||||
type TestEntity = { name: string };
|
||||
|
||||
const mockEntity: TestEntity = { name: 'test-container' };
|
||||
|
||||
const NODE_PARAMS = {
|
||||
selectedItem: 'node-1',
|
||||
category: InfraMonitoringEntity.NODES,
|
||||
clusterName: null,
|
||||
namespaceName: null,
|
||||
};
|
||||
|
||||
/** What the drawer records before opening a resource from the node's overview */
|
||||
const NODE_ENTRY = {
|
||||
params: NODE_PARAMS,
|
||||
category: InfraMonitoringEntity.NODES,
|
||||
label: 'node-1',
|
||||
entity: { name: 'node-1' },
|
||||
queryKeyPrefix: 'node',
|
||||
};
|
||||
|
||||
function renderDrawer(onUrlUpdate?: OnUrlUpdateFunction): void {
|
||||
render(
|
||||
<NuqsTestingAdapter
|
||||
searchParams={{
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: 'container-1',
|
||||
}}
|
||||
onUrlUpdate={onUrlUpdate}
|
||||
>
|
||||
<K8sBaseDetails<TestEntity>
|
||||
category={InfraMonitoringEntity.CONTAINERS}
|
||||
eventCategory={InfraMonitoringEvents.Pod}
|
||||
getSelectedItemExpression={(): string => "k8s.pod.uid = 'abc'"}
|
||||
fetchEntityData={jest
|
||||
.fn()
|
||||
.mockResolvedValue({ data: mockEntity, error: null })}
|
||||
getEntityName={(entity): string => entity.name}
|
||||
getInitialLogTracesExpression={(): string => ''}
|
||||
getInitialEventsExpression={(): string => ''}
|
||||
metadataConfig={[
|
||||
{ label: 'Name', getValue: (entity): string => entity.name },
|
||||
]}
|
||||
entityWidgetInfo={[{ title: 'CPU', yAxisUnit: 'percent' }]}
|
||||
getEntityQueryPayload={jest.fn().mockReturnValue([])}
|
||||
queryKeyPrefix="testContainer"
|
||||
/>
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('K8sBaseDetails - back navigation', () => {
|
||||
beforeEach(() => {
|
||||
useDrawerHistoryStore.getState().reset();
|
||||
});
|
||||
|
||||
it('closes rather than going back when opened from the list', async () => {
|
||||
act(() => {
|
||||
renderDrawer();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('test-container').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('drawer-back-button')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('close-drawer-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('replaces the close control while there is somewhere to go back to', async () => {
|
||||
useDrawerHistoryStore.getState().push(NODE_ENTRY);
|
||||
|
||||
act(() => {
|
||||
renderDrawer();
|
||||
});
|
||||
|
||||
const backButton = await screen.findByTestId('drawer-back-button');
|
||||
|
||||
// The destination is text rather than a tooltip, which a click would dismiss
|
||||
expect(backButton).toHaveTextContent('Back to Node');
|
||||
expect(backButton).toHaveAccessibleName('Back to Node: node-1');
|
||||
expect(screen.queryByTestId('close-drawer-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns to the resource this one was opened from', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = jest.fn();
|
||||
|
||||
useDrawerHistoryStore.getState().push(NODE_ENTRY);
|
||||
|
||||
act(() => {
|
||||
renderDrawer(onUrlUpdate);
|
||||
});
|
||||
|
||||
const backButton = await screen.findByTestId('drawer-back-button');
|
||||
await user.click(backButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const params = onUrlUpdate.mock.calls.map((call) => call[0].searchParams);
|
||||
expect(
|
||||
params.some(
|
||||
(searchParams) =>
|
||||
searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM) ===
|
||||
'node-1' &&
|
||||
searchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY,
|
||||
) === InfraMonitoringEntity.NODES,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// The trail is one step shorter, so a second back closes instead
|
||||
expect(useDrawerHistoryStore.getState().entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('drops the trail when the drawer is dismissed', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
useDrawerHistoryStore.getState().push(NODE_ENTRY);
|
||||
|
||||
act(() => {
|
||||
renderDrawer();
|
||||
});
|
||||
|
||||
await screen.findByTestId('drawer-back-button');
|
||||
// Dismissing from outside the drawer, the way a click on the overlay does
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useDrawerHistoryStore.getState().entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ type TestEntity = {
|
||||
name: string;
|
||||
namespace: string;
|
||||
cluster: string;
|
||||
meta?: Record<string, string>;
|
||||
};
|
||||
|
||||
const mockEntity: TestEntity = {
|
||||
@@ -29,14 +30,22 @@ const mockEntity: TestEntity = {
|
||||
cluster: 'test-cluster',
|
||||
};
|
||||
|
||||
function createBaseProps() {
|
||||
/** The overview tab appears only for a resource whose meta relates it to others */
|
||||
const relatedEntity: TestEntity = {
|
||||
...mockEntity,
|
||||
meta: {
|
||||
'k8s.pod.uid': '63edbad0-398e-48e5-baba-f7ded7c73f1b',
|
||||
'k8s.namespace.name': 'default',
|
||||
'k8s.cluster.name': 'test-cluster',
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseProps(category: InfraMonitoringEntity, entity = mockEntity) {
|
||||
return {
|
||||
category: InfraMonitoringEntity.PODS,
|
||||
category,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
getSelectedItemExpression: (): string => 'k8s.pod.name = "test-pod"',
|
||||
fetchEntityData: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ data: mockEntity, error: null }),
|
||||
fetchEntityData: jest.fn().mockResolvedValue({ data: entity, error: null }),
|
||||
getEntityName: (e: TestEntity): string => e.name,
|
||||
getInitialLogTracesExpression: (): string => 'k8s.pod.name = "test-pod"',
|
||||
getInitialEventsExpression: (): string => 'k8s.pod.name = "test-pod"',
|
||||
@@ -51,7 +60,10 @@ function createBaseProps() {
|
||||
|
||||
interface RenderOptions {
|
||||
view?: string;
|
||||
category?: InfraMonitoringEntity;
|
||||
entity?: TestEntity;
|
||||
tabsConfig?: {
|
||||
showOverview?: boolean;
|
||||
showMetrics?: boolean;
|
||||
showLogs?: boolean;
|
||||
showTraces?: boolean;
|
||||
@@ -67,6 +79,9 @@ interface RenderOptions {
|
||||
|
||||
function renderK8sBaseDetails({
|
||||
view = VIEW_TYPES.METRICS,
|
||||
// Volumes have no related resources, so they get no overview tab
|
||||
category = InfraMonitoringEntity.VOLUMES,
|
||||
entity = mockEntity,
|
||||
tabsConfig,
|
||||
customTabs,
|
||||
}: RenderOptions = {}) {
|
||||
@@ -78,7 +93,7 @@ function renderK8sBaseDetails({
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
<K8sBaseDetails<TestEntity>
|
||||
{...createBaseProps()}
|
||||
{...createBaseProps(category, entity)}
|
||||
tabsConfig={tabsConfig}
|
||||
customTabs={customTabs}
|
||||
/>
|
||||
@@ -106,6 +121,25 @@ describe('K8sBaseDetails - Tab Validation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should land on the overview tab for a category with related resources', async () => {
|
||||
act(() => {
|
||||
renderK8sBaseDetails({
|
||||
view: 'invalid-tab',
|
||||
category: InfraMonitoringEntity.PODS,
|
||||
entity: relatedEntity,
|
||||
tabsConfig: { showOverview: true },
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('test-pod').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSelectedTabText()).toContain('Overview');
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset to first available tab when METRICS is disabled and view is invalid', async () => {
|
||||
act(() => {
|
||||
renderK8sBaseDetails({
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { InfraMonitoringEntity } from '../../constants';
|
||||
import {
|
||||
buildRelatedFilterExpression,
|
||||
carryOverListFilters,
|
||||
getRelatedCategories,
|
||||
} from '../relations';
|
||||
|
||||
/** What /api/v2/infra_monitoring/pods returns as a pod's meta. */
|
||||
const POD_ATTRIBUTES = {
|
||||
'k8s.pod.uid': '63edbad0-398e-48e5-baba-f7ded7c73f1b',
|
||||
'k8s.pod.name': 'checkout-7d9f',
|
||||
'k8s.namespace.name': 'shop',
|
||||
'k8s.cluster.name': 'prod',
|
||||
'k8s.node.name': 'node-1',
|
||||
'k8s.deployment.name': 'checkout',
|
||||
};
|
||||
|
||||
const NODE_ATTRIBUTES = {
|
||||
'k8s.node.name': 'node-1',
|
||||
'k8s.cluster.name': 'prod',
|
||||
};
|
||||
|
||||
describe('getRelatedCategories', () => {
|
||||
it('offers every other kubernetes category, never itself', () => {
|
||||
expect(
|
||||
getRelatedCategories(InfraMonitoringEntity.PODS, POD_ATTRIBUTES),
|
||||
).toStrictEqual([
|
||||
InfraMonitoringEntity.CONTAINERS,
|
||||
InfraMonitoringEntity.DEPLOYMENTS,
|
||||
InfraMonitoringEntity.STATEFULSETS,
|
||||
InfraMonitoringEntity.DAEMONSETS,
|
||||
InfraMonitoringEntity.JOBS,
|
||||
InfraMonitoringEntity.VOLUMES,
|
||||
InfraMonitoringEntity.NODES,
|
||||
InfraMonitoringEntity.NAMESPACES,
|
||||
InfraMonitoringEntity.CLUSTERS,
|
||||
]);
|
||||
});
|
||||
|
||||
it('scopes a category it has no relation to by what they share', () => {
|
||||
// Not this pod's statefulsets, which it has none of: the ones beside it
|
||||
expect(
|
||||
buildRelatedFilterExpression(
|
||||
InfraMonitoringEntity.PODS,
|
||||
InfraMonitoringEntity.STATEFULSETS,
|
||||
POD_ATTRIBUTES,
|
||||
),
|
||||
).toBe("k8s.cluster.name = 'prod' AND k8s.namespace.name = 'shop'");
|
||||
});
|
||||
|
||||
it('falls back to the cluster where that is all two categories share', () => {
|
||||
// Deployment metrics carry no node, so this relation is unexpressible
|
||||
expect(
|
||||
buildRelatedFilterExpression(
|
||||
InfraMonitoringEntity.NODES,
|
||||
InfraMonitoringEntity.DEPLOYMENTS,
|
||||
NODE_ATTRIBUTES,
|
||||
),
|
||||
).toBe("k8s.cluster.name = 'prod'");
|
||||
});
|
||||
|
||||
it('finds nothing for a host, which is not a kubernetes resource', () => {
|
||||
// Host rows carry os.type and the host name, no kubernetes attribute
|
||||
expect(
|
||||
getRelatedCategories(InfraMonitoringEntity.HOSTS, {
|
||||
'host.name': 'ip-10-0-0-1',
|
||||
'os.type': 'linux',
|
||||
}),
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('lists pods first, which makes them the default for a node', () => {
|
||||
expect(
|
||||
getRelatedCategories(InfraMonitoringEntity.NODES, NODE_ATTRIBUTES)[0],
|
||||
).toBe(InfraMonitoringEntity.PODS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRelatedFilterExpression', () => {
|
||||
it('scopes the pods of a node by the node, inside its cluster', () => {
|
||||
expect(
|
||||
buildRelatedFilterExpression(
|
||||
InfraMonitoringEntity.NODES,
|
||||
InfraMonitoringEntity.PODS,
|
||||
NODE_ATTRIBUTES,
|
||||
),
|
||||
).toBe("k8s.cluster.name = 'prod' AND k8s.node.name = 'node-1'");
|
||||
});
|
||||
|
||||
it("scopes a pod's containers by the pod uid, which those rows carry", () => {
|
||||
expect(
|
||||
buildRelatedFilterExpression(
|
||||
InfraMonitoringEntity.PODS,
|
||||
InfraMonitoringEntity.CONTAINERS,
|
||||
POD_ATTRIBUTES,
|
||||
),
|
||||
).toBe(
|
||||
"k8s.cluster.name = 'prod' AND k8s.namespace.name = 'shop' AND k8s.pod.uid = '63edbad0-398e-48e5-baba-f7ded7c73f1b'",
|
||||
);
|
||||
});
|
||||
|
||||
it("reaches a pod's deployment by name, the only key that endpoint has", () => {
|
||||
expect(
|
||||
buildRelatedFilterExpression(
|
||||
InfraMonitoringEntity.PODS,
|
||||
InfraMonitoringEntity.DEPLOYMENTS,
|
||||
POD_ATTRIBUTES,
|
||||
),
|
||||
).toBe(
|
||||
"k8s.cluster.name = 'prod' AND k8s.namespace.name = 'shop' AND k8s.deployment.name = 'checkout'",
|
||||
);
|
||||
});
|
||||
|
||||
it('names the parent when the drawer resource belongs to it', () => {
|
||||
expect(
|
||||
buildRelatedFilterExpression(
|
||||
InfraMonitoringEntity.PODS,
|
||||
InfraMonitoringEntity.NODES,
|
||||
POD_ATTRIBUTES,
|
||||
),
|
||||
).toBe("k8s.cluster.name = 'prod' AND k8s.node.name = 'node-1'");
|
||||
});
|
||||
|
||||
it('asks a cluster list for the cluster alone', () => {
|
||||
expect(
|
||||
buildRelatedFilterExpression(
|
||||
InfraMonitoringEntity.PODS,
|
||||
InfraMonitoringEntity.CLUSTERS,
|
||||
POD_ATTRIBUTES,
|
||||
),
|
||||
).toBe("k8s.cluster.name = 'prod'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('carryOverListFilters', () => {
|
||||
it('keeps the clauses the target endpoint understands', () => {
|
||||
expect(
|
||||
carryOverListFilters(
|
||||
"k8s.namespace.name = 'shop' AND k8s.node.condition_ready = true",
|
||||
InfraMonitoringEntity.PODS,
|
||||
),
|
||||
).toBe("k8s.namespace.name = 'shop'");
|
||||
});
|
||||
|
||||
it('drops a filter written against the source entity only', () => {
|
||||
expect(
|
||||
carryOverListFilters('k8s.node.cpu.usage > 0.5', InfraMonitoringEntity.PODS),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('returns nothing for an empty list filter', () => {
|
||||
expect(carryOverListFilters(' ', InfraMonitoringEntity.PODS)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Bolt,
|
||||
Box,
|
||||
Boxes,
|
||||
Computer,
|
||||
Container,
|
||||
FilePenLine,
|
||||
Group,
|
||||
HardDrive,
|
||||
Server,
|
||||
Workflow,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
|
||||
/**
|
||||
* One icon per category, so the section tabs and the drawer that opens from
|
||||
* them show a resource the same way.
|
||||
*/
|
||||
type IconSize = ComponentProps<typeof Container>['size'];
|
||||
|
||||
const CATEGORY_ICONS: Record<InfraMonitoringEntity, typeof Container> = {
|
||||
[InfraMonitoringEntity.HOSTS]: Server,
|
||||
[InfraMonitoringEntity.PODS]: Container,
|
||||
[InfraMonitoringEntity.CONTAINERS]: Box,
|
||||
[InfraMonitoringEntity.NODES]: Workflow,
|
||||
[InfraMonitoringEntity.NAMESPACES]: FilePenLine,
|
||||
[InfraMonitoringEntity.CLUSTERS]: Boxes,
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]: Computer,
|
||||
[InfraMonitoringEntity.STATEFULSETS]: ArrowUpDown,
|
||||
[InfraMonitoringEntity.DAEMONSETS]: Group,
|
||||
[InfraMonitoringEntity.JOBS]: Bolt,
|
||||
[InfraMonitoringEntity.VOLUMES]: HardDrive,
|
||||
};
|
||||
|
||||
export function CategoryIcon({
|
||||
category,
|
||||
size = 14,
|
||||
}: {
|
||||
category: InfraMonitoringEntity;
|
||||
size?: IconSize;
|
||||
}): JSX.Element {
|
||||
const Icon = CATEGORY_ICONS[category];
|
||||
|
||||
return <Icon size={size} />;
|
||||
}
|
||||
@@ -3,18 +3,11 @@ import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
import { TextNoData } from '../../../components/TextNoData';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../../../constants';
|
||||
import { InfraMonitoringEntity } from '../../../constants';
|
||||
import { getDrawerDurationMs } from '../../useDrawerLifecycleStore';
|
||||
import { buildK8sListNavigationUrl } from '../../utils';
|
||||
import styles from './EntityCountsSection.module.scss';
|
||||
import { logInfraExplorerNavigatedEvent } from 'container/InfraMonitoringK8sV2/Base/events';
|
||||
|
||||
@@ -55,70 +48,6 @@ export function EntityCountsSection<T>({
|
||||
closeDrawer();
|
||||
};
|
||||
|
||||
const buildNavigationUrl = (targetCategory: InfraMonitoringEntity): string => {
|
||||
const defaultQuery = initialQueriesMap[DataSource.METRICS];
|
||||
|
||||
const compositeQuery = {
|
||||
...defaultQuery,
|
||||
id: uuid(),
|
||||
builder: {
|
||||
...defaultQuery.builder,
|
||||
queryData: defaultQuery.builder.queryData.map((query) => ({
|
||||
...query,
|
||||
filter: { expression: filterExpression },
|
||||
filters: { items: [], op: 'AND' as const },
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, targetCategory);
|
||||
urlParams.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const currentSearchParams = new URLSearchParams(window.location.search);
|
||||
const detailRelativeTime = currentSearchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
|
||||
);
|
||||
const detailStartTime = currentSearchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
|
||||
);
|
||||
const detailEndTime = currentSearchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
|
||||
);
|
||||
|
||||
const listRelativeTime = currentSearchParams.get(QueryParams.relativeTime);
|
||||
const listStartTime = currentSearchParams.get(QueryParams.startTime);
|
||||
const listEndTime = currentSearchParams.get(QueryParams.endTime);
|
||||
|
||||
if (listRelativeTime) {
|
||||
urlParams.set(QueryParams.relativeTime, listRelativeTime);
|
||||
} else if (listStartTime && listEndTime) {
|
||||
urlParams.set(QueryParams.startTime, listStartTime);
|
||||
urlParams.set(QueryParams.endTime, listEndTime);
|
||||
}
|
||||
|
||||
if (detailRelativeTime) {
|
||||
urlParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
|
||||
detailRelativeTime,
|
||||
);
|
||||
} else if (detailStartTime && detailEndTime) {
|
||||
urlParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
|
||||
detailStartTime,
|
||||
);
|
||||
urlParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
|
||||
detailEndTime,
|
||||
);
|
||||
}
|
||||
|
||||
return `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${urlParams.toString()}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.countsContainer}>
|
||||
{countsConfig.map((config) => (
|
||||
@@ -147,7 +76,7 @@ export function EntityCountsSection<T>({
|
||||
<TextNoData type="typography" className={styles.countValue} />
|
||||
)}
|
||||
<Link
|
||||
to={buildNavigationUrl(config.targetCategory)}
|
||||
to={buildK8sListNavigationUrl(config.targetCategory, filterExpression)}
|
||||
onClick={(): void => handleCardNavigate(config.label)}
|
||||
data-testid={`navigate-${config.label.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
>
|
||||
|
||||
@@ -97,7 +97,11 @@ export function logInfraExplorerNavigatedEvent(params: {
|
||||
| 'logs_explorer'
|
||||
| 'traces_explorer'
|
||||
| 'k8s_list';
|
||||
source: 'chart_compass_icon' | 'tab_cta_button' | 'stats_card';
|
||||
source:
|
||||
| 'chart_compass_icon'
|
||||
| 'tab_cta_button'
|
||||
| 'stats_card'
|
||||
| 'overview_cta';
|
||||
tab: string;
|
||||
sourceKey: string | null;
|
||||
drawerDurationMsAtNavigation: number | null;
|
||||
|
||||
265
frontend/src/container/InfraMonitoringK8sV2/Base/relations.ts
Normal file
265
frontend/src/container/InfraMonitoringK8sV2/Base/relations.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
|
||||
/** The attribute that names one record of a category. */
|
||||
const PRIMARY_KEY: Record<InfraMonitoringEntity, string> = {
|
||||
[InfraMonitoringEntity.CLUSTERS]: INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
[InfraMonitoringEntity.NAMESPACES]:
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
[InfraMonitoringEntity.NODES]: INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
|
||||
[InfraMonitoringEntity.PODS]: INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
|
||||
[InfraMonitoringEntity.CONTAINERS]:
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]:
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
|
||||
[InfraMonitoringEntity.STATEFULSETS]:
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
|
||||
[InfraMonitoringEntity.DAEMONSETS]:
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
|
||||
[InfraMonitoringEntity.JOBS]: INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
|
||||
[InfraMonitoringEntity.VOLUMES]:
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
|
||||
[InfraMonitoringEntity.HOSTS]: INFRA_MONITORING_ATTR_KEYS.HOST_NAME,
|
||||
};
|
||||
|
||||
/**
|
||||
* Attributes each list endpoint can be filtered by, broadest first so a built
|
||||
* expression reads cluster to container. Only keys the category's metrics
|
||||
* actually carry belong here.
|
||||
*/
|
||||
const FILTERABLE_KEYS: Record<InfraMonitoringEntity, string[]> = {
|
||||
[InfraMonitoringEntity.CLUSTERS]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
],
|
||||
[InfraMonitoringEntity.NODES]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
|
||||
],
|
||||
[InfraMonitoringEntity.NAMESPACES]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
],
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
|
||||
],
|
||||
[InfraMonitoringEntity.STATEFULSETS]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
|
||||
],
|
||||
[InfraMonitoringEntity.DAEMONSETS]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
|
||||
],
|
||||
[InfraMonitoringEntity.JOBS]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
|
||||
],
|
||||
[InfraMonitoringEntity.VOLUMES]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_PERSISTENT_VOLUME_CLAIM_NAME,
|
||||
],
|
||||
[InfraMonitoringEntity.PODS]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
|
||||
],
|
||||
[InfraMonitoringEntity.CONTAINERS]: [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NODE_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DEPLOYMENT_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_STATEFULSET_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_DAEMONSET_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_JOB_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_POD_UID,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CONTAINER_NAME,
|
||||
],
|
||||
[InfraMonitoringEntity.HOSTS]: [],
|
||||
};
|
||||
|
||||
/** Dropdown order: what a resource usually contains, then what contains it. */
|
||||
const DISPLAY_ORDER: InfraMonitoringEntity[] = [
|
||||
InfraMonitoringEntity.PODS,
|
||||
InfraMonitoringEntity.CONTAINERS,
|
||||
InfraMonitoringEntity.DEPLOYMENTS,
|
||||
InfraMonitoringEntity.STATEFULSETS,
|
||||
InfraMonitoringEntity.DAEMONSETS,
|
||||
InfraMonitoringEntity.JOBS,
|
||||
InfraMonitoringEntity.VOLUMES,
|
||||
InfraMonitoringEntity.NODES,
|
||||
InfraMonitoringEntity.NAMESPACES,
|
||||
InfraMonitoringEntity.CLUSTERS,
|
||||
];
|
||||
|
||||
export type EntityAttributes = Record<string, string>;
|
||||
|
||||
/** The attribute a category's display name belongs under. */
|
||||
const NAME_KEY: Partial<Record<InfraMonitoringEntity, string>> = {
|
||||
[InfraMonitoringEntity.PODS]: INFRA_MONITORING_ATTR_KEYS.K8S_POD_NAME,
|
||||
};
|
||||
|
||||
export function getEntityNameAttributeKey(
|
||||
category: InfraMonitoringEntity,
|
||||
): string {
|
||||
return NAME_KEY[category] ?? PRIMARY_KEY[category];
|
||||
}
|
||||
|
||||
function matchingKeys(
|
||||
target: InfraMonitoringEntity,
|
||||
attributes: EntityAttributes,
|
||||
): string[] {
|
||||
return FILTERABLE_KEYS[target].filter((key) => !!attributes[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The attribute that ties a target to the drawer's resource: the resource
|
||||
* itself when the target's records belong to it (the containers of this pod),
|
||||
* otherwise the target's own name as the resource knows it (the node this pod
|
||||
* runs on). Null when neither holds, which leaves the target scoped only by
|
||||
* the ancestors the two share.
|
||||
*/
|
||||
function relationKey(
|
||||
source: InfraMonitoringEntity,
|
||||
target: InfraMonitoringEntity,
|
||||
attributes: EntityAttributes,
|
||||
): string | null {
|
||||
if (target === source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keys = matchingKeys(target, attributes);
|
||||
|
||||
if (keys.includes(PRIMARY_KEY[source])) {
|
||||
return PRIMARY_KEY[source];
|
||||
}
|
||||
|
||||
return keys.includes(PRIMARY_KEY[target]) ? PRIMARY_KEY[target] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every category the drawer's resource can scope a list of, which for a
|
||||
* kubernetes resource is all of them: where no closer relation exists the two
|
||||
* still share a cluster. A target is left out only when nothing is shared at
|
||||
* all, so a list is never asked for unscoped.
|
||||
*/
|
||||
export function getRelatedCategories(
|
||||
source: InfraMonitoringEntity,
|
||||
attributes: EntityAttributes,
|
||||
): InfraMonitoringEntity[] {
|
||||
return DISPLAY_ORDER.filter(
|
||||
(target) =>
|
||||
target !== source &&
|
||||
buildRelatedFilterClauses(source, target, attributes).length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cluster and namespace ride along with the relation itself, the way other
|
||||
* observability tools scope these lists: a pod's deployment is read as that
|
||||
* deployment, in that namespace, in that cluster.
|
||||
*/
|
||||
const SCOPE_KEYS = [
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_CLUSTER_NAME,
|
||||
INFRA_MONITORING_ATTR_KEYS.K8S_NAMESPACE_NAME,
|
||||
];
|
||||
|
||||
export interface RelatedFilterClause {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope clauses, broadest first. The last carries the relation where one
|
||||
* exists; without one the list is scoped to the shared ancestors, which reads
|
||||
* as "beside this resource" rather than "belonging to it".
|
||||
*/
|
||||
export function buildRelatedFilterClauses(
|
||||
source: InfraMonitoringEntity,
|
||||
target: InfraMonitoringEntity,
|
||||
attributes: EntityAttributes,
|
||||
): RelatedFilterClause[] {
|
||||
const key = relationKey(source, target, attributes);
|
||||
|
||||
const scope = SCOPE_KEYS.filter(
|
||||
(scopeKey) =>
|
||||
scopeKey !== key &&
|
||||
FILTERABLE_KEYS[target].includes(scopeKey) &&
|
||||
!!attributes[scopeKey],
|
||||
);
|
||||
|
||||
return (key ? [...scope, key] : scope).map((clauseKey) => ({
|
||||
key: clauseKey,
|
||||
value: attributes[clauseKey] as string,
|
||||
}));
|
||||
}
|
||||
|
||||
export function formatRelatedFilterClause(clause: RelatedFilterClause): string {
|
||||
return `${clause.key} = ${formatValueForExpression(clause.value)}`;
|
||||
}
|
||||
|
||||
export function buildRelatedFilterExpression(
|
||||
source: InfraMonitoringEntity,
|
||||
target: InfraMonitoringEntity,
|
||||
attributes: EntityAttributes,
|
||||
): string {
|
||||
return buildRelatedFilterClauses(source, target, attributes)
|
||||
.map(formatRelatedFilterClause)
|
||||
.join(' AND ');
|
||||
}
|
||||
|
||||
export function resolveRelatedCategory(
|
||||
source: InfraMonitoringEntity,
|
||||
attributes: EntityAttributes,
|
||||
requested: string | null,
|
||||
): InfraMonitoringEntity | null {
|
||||
const related = getRelatedCategories(source, attributes);
|
||||
const match = related.find((target) => target === requested);
|
||||
|
||||
return match ?? related[0] ?? null;
|
||||
}
|
||||
|
||||
export function carryOverListFilters(
|
||||
expression: string,
|
||||
target: InfraMonitoringEntity,
|
||||
): string {
|
||||
if (!expression.trim()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const supported = new Set(FILTERABLE_KEYS[target]);
|
||||
|
||||
return extractQueryPairs(expression)
|
||||
.filter((pair) => supported.has(pair.key))
|
||||
.map((pair) => {
|
||||
const start = pair.position.negationStart ?? pair.position.keyStart;
|
||||
const lastValue = pair.valuesPosition?.[pair.valuesPosition.length - 1];
|
||||
const end =
|
||||
lastValue?.end ?? pair.position.valueEnd ?? pair.position.operatorEnd;
|
||||
|
||||
return expression.slice(start, end + 1).trim();
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' AND ');
|
||||
}
|
||||
@@ -75,6 +75,8 @@ export type GetEntityQueryPayload<T> = (
|
||||
) => GetQueryResultsProps[];
|
||||
|
||||
export interface K8sDetailsTabsConfig {
|
||||
/** Declared by categories whose resources relate to others; see relations.ts */
|
||||
showOverview?: boolean;
|
||||
showMetrics?: boolean;
|
||||
showLogs?: boolean;
|
||||
showTraces?: boolean;
|
||||
@@ -141,6 +143,10 @@ export interface K8sBaseDetailsContentProps<T> {
|
||||
customTabs?: K8sDetailsCustomTab<T>[];
|
||||
logsAndTracesInitialExpression: string;
|
||||
eventsInitialExpression: string;
|
||||
/** The drawer entity's own attributes, used to find and scope related resources */
|
||||
entityAttributes: Record<string, string>;
|
||||
/** Name of the drawer entity, for the back control of a resource opened from it */
|
||||
entityName: string;
|
||||
}
|
||||
|
||||
// Aliases for backward compatibility
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
|
||||
export interface DrawerHistoryEntry {
|
||||
params: SelectedItemParams;
|
||||
/** Category of the resource that was open, which the back control names */
|
||||
category: InfraMonitoringEntity;
|
||||
/** Name of that resource, shown on hover */
|
||||
label: string;
|
||||
/**
|
||||
* The record the drawer was showing, and the prefix of the query that holds
|
||||
* it, so stepping back renders from it instead of fetching again.
|
||||
*/
|
||||
entity: unknown;
|
||||
queryKeyPrefix: string;
|
||||
}
|
||||
|
||||
export interface IDrawerHistoryStore {
|
||||
entries: DrawerHistoryEntry[];
|
||||
push: (entry: DrawerHistoryEntry) => void;
|
||||
pop: () => DrawerHistoryEntry | null;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The trail left by opening one resource from another's overview tab, so the
|
||||
* drawer can step back through it instead of only closing.
|
||||
*/
|
||||
export const useDrawerHistoryStore = create<IDrawerHistoryStore>()(
|
||||
(set, get) => ({
|
||||
entries: [],
|
||||
push: (entry): void => set({ entries: [...get().entries, entry] }),
|
||||
pop: (): DrawerHistoryEntry | null => {
|
||||
const { entries } = get();
|
||||
const previous = entries[entries.length - 1] ?? null;
|
||||
|
||||
if (previous) {
|
||||
set({ entries: entries.slice(0, -1) });
|
||||
}
|
||||
|
||||
return previous;
|
||||
},
|
||||
reset: (): void => set({ entries: [] }),
|
||||
}),
|
||||
);
|
||||
|
||||
export const resetDrawerHistory = (): void =>
|
||||
useDrawerHistoryStore.getState().reset();
|
||||
|
||||
export const pushDrawerHistory = (entry: DrawerHistoryEntry): void =>
|
||||
useDrawerHistoryStore.getState().push(entry);
|
||||
|
||||
export const getDrawerHistoryDepth = (): number =>
|
||||
useDrawerHistoryStore.getState().entries.length;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
|
||||
interface PrimeEntityDetailsParams {
|
||||
params: SelectedItemParams;
|
||||
queryKeyPrefix: string;
|
||||
entity: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds the drawer's entity query with a record already in hand, so navigating
|
||||
* between resources renders straight away instead of passing through a loading
|
||||
* state. The list page does the same on a row click.
|
||||
*/
|
||||
export function usePrimeEntityDetails(): (
|
||||
params: PrimeEntityDetailsParams,
|
||||
) => void {
|
||||
const queryClient = useQueryClient();
|
||||
const selectedTime = useGlobalTimeStore((store) => store.selectedTime);
|
||||
const getAutoRefreshQueryKey = useGlobalTimeStore(
|
||||
(store) => store.getAutoRefreshQueryKey,
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
({ params, queryKeyPrefix, entity }): void => {
|
||||
if (!queryKeyPrefix || !entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData(
|
||||
getAutoRefreshQueryKey(
|
||||
selectedTime,
|
||||
`${queryKeyPrefix}EntityDetails`,
|
||||
params.selectedItem,
|
||||
params.clusterName,
|
||||
params.namespaceName,
|
||||
params.containerName,
|
||||
),
|
||||
{ data: entity },
|
||||
);
|
||||
},
|
||||
[queryClient, selectedTime, getAutoRefreshQueryKey],
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,16 @@ import {
|
||||
formatValueForExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { SelectedItemParams } from 'container/InfraMonitoringK8sV2/hooks';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
export function sortByColumnOrder<T>(
|
||||
items: T[],
|
||||
@@ -178,3 +187,74 @@ export function buildExpressionFromSelectedItemParams(
|
||||
|
||||
return clauses.join(' AND ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Link to the k8s list page for `targetCategory`, pre-filtered by `filterExpression`
|
||||
* and carrying whichever time range (list or drawer) the current URL holds.
|
||||
*/
|
||||
export function buildK8sListNavigationUrl(
|
||||
targetCategory: InfraMonitoringEntity,
|
||||
filterExpression: string,
|
||||
): string {
|
||||
const defaultQuery = initialQueriesMap[DataSource.METRICS];
|
||||
|
||||
const compositeQuery = {
|
||||
...defaultQuery,
|
||||
id: uuid(),
|
||||
builder: {
|
||||
...defaultQuery.builder,
|
||||
queryData: defaultQuery.builder.queryData.map((query) => ({
|
||||
...query,
|
||||
filter: { expression: filterExpression },
|
||||
filters: { items: [], op: 'AND' as const },
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set(INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY, targetCategory);
|
||||
urlParams.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQuery)),
|
||||
);
|
||||
|
||||
const currentSearchParams = new URLSearchParams(window.location.search);
|
||||
const detailRelativeTime = currentSearchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
|
||||
);
|
||||
const detailStartTime = currentSearchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
|
||||
);
|
||||
const detailEndTime = currentSearchParams.get(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
|
||||
);
|
||||
|
||||
const listRelativeTime = currentSearchParams.get(QueryParams.relativeTime);
|
||||
const listStartTime = currentSearchParams.get(QueryParams.startTime);
|
||||
const listEndTime = currentSearchParams.get(QueryParams.endTime);
|
||||
|
||||
if (listRelativeTime) {
|
||||
urlParams.set(QueryParams.relativeTime, listRelativeTime);
|
||||
} else if (listStartTime && listEndTime) {
|
||||
urlParams.set(QueryParams.startTime, listStartTime);
|
||||
urlParams.set(QueryParams.endTime, listEndTime);
|
||||
}
|
||||
|
||||
if (detailRelativeTime) {
|
||||
urlParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_RELATIVE_TIME,
|
||||
detailRelativeTime,
|
||||
);
|
||||
} else if (detailStartTime && detailEndTime) {
|
||||
urlParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_START_TIME,
|
||||
detailStartTime,
|
||||
);
|
||||
urlParams.set(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.DETAIL_END_TIME,
|
||||
detailEndTime,
|
||||
);
|
||||
}
|
||||
|
||||
return `${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}?${urlParams.toString()}`;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import {
|
||||
K8sDetailsCountConfig,
|
||||
K8sDetailsMetadataConfig,
|
||||
} from '../Base/K8sBaseDetails';
|
||||
import { K8sDetailsCountConfig, K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
|
||||
@@ -123,6 +123,7 @@ export const clusterEntityConfig: K8sEntityConfig<InframonitoringtypesClusterRec
|
||||
details: {
|
||||
category: InfraMonitoringEntity.CLUSTERS,
|
||||
eventCategory: InfraMonitoringEvents.Cluster,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'cluster',
|
||||
getSelectedItemExpression: k8sClusterGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -127,6 +127,7 @@ export const containerEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.CONTAINERS,
|
||||
eventCategory: InfraMonitoringEvents.Container,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'container',
|
||||
getSelectedItemExpression: k8sContainerGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Container } from '@signozhq/icons';
|
||||
import { Box } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import {
|
||||
InframonitoringtypesContainerReadyDTO,
|
||||
@@ -94,7 +94,7 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Container Name"
|
||||
icon={<Container data-hide-expanded="true" size={14} />}
|
||||
icon={<Box data-hide-expanded="true" size={14} />}
|
||||
docPath={`${CONTAINERS_DOC_PATH}#container-name`}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import {
|
||||
getPodUtilizationByPodQueryPayloads,
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
|
||||
@@ -132,6 +132,7 @@ export const daemonSetEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.DAEMONSETS,
|
||||
eventCategory: InfraMonitoringEvents.DaemonSet,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'daemonset',
|
||||
getSelectedItemExpression: k8sDaemonSetGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import {
|
||||
getPodUtilizationByPodQueryPayloads,
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
|
||||
@@ -147,6 +147,7 @@ export const deploymentEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.DEPLOYMENTS,
|
||||
eventCategory: InfraMonitoringEvents.Deployment,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'deployment',
|
||||
getSelectedItemExpression: k8sDeploymentGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
margin-top: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.resourcePicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.resourceTrigger {
|
||||
--select-trigger-width: 240px;
|
||||
--select-trigger-height: 2rem;
|
||||
}
|
||||
|
||||
.resourceOptions {
|
||||
// The drawer is a dialog at z-index 50 and select content ships without one,
|
||||
// so without this the dropdown paints behind the drawer.
|
||||
z-index: 60;
|
||||
|
||||
// Radix measures the trigger for us; without this the list is narrower than it
|
||||
min-width: var(--radix-select-trigger-width);
|
||||
}
|
||||
|
||||
.filterRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
padding-left: var(--spacing-3);
|
||||
min-width: 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l1-background);
|
||||
|
||||
// The row draws the field outline, so the editor inside it draws none
|
||||
--query-builder-v2-border-color: transparent;
|
||||
--query-builder-v2-background-color: transparent;
|
||||
}
|
||||
|
||||
.scopeChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 420px;
|
||||
padding: var(--spacing-1) var(--spacing-3);
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.scopeMore {
|
||||
flex: none;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
// The editor beside it is CodeMirror on the copilot theme in dark and
|
||||
// githubLight in light; the chip borrows those token colours so one filter
|
||||
// does not read as two
|
||||
.scopeKey {
|
||||
color: #939da5;
|
||||
}
|
||||
|
||||
.scopeOperator {
|
||||
color: #939da5;
|
||||
}
|
||||
|
||||
.scopeValue {
|
||||
color: #5bec95;
|
||||
}
|
||||
|
||||
:global(.lightMode) {
|
||||
.scopeKey,
|
||||
.scopeOperator {
|
||||
color: #005cc5;
|
||||
}
|
||||
|
||||
.scopeValue {
|
||||
color: #032f62;
|
||||
}
|
||||
}
|
||||
|
||||
.scopeExpression {
|
||||
font-family: var(--font-mono, monospace);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filterInput {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
// The locked scope is already shown as a chip, so drop the editor's own
|
||||
// scope marker and the room it reserves for it
|
||||
:global(.query-search-initial-scope-label) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(
|
||||
.query-where-clause-editor.hasInitialExpression .cm-editor .cm-content
|
||||
) {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
// Its page-width minimum would force the filter row to overflow the drawer
|
||||
:global(.code-mirror-where-clause .query-where-clause-editor) {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tableCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 480px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
--typography-color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.noRelations {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 240px;
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 180px;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { rest } from 'msw';
|
||||
|
||||
import { countControl } from '@/storybook/controls/controls';
|
||||
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
function buildContainers(count: number): unknown[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
containerName: index === 0 ? 'loadgenerator' : `sidecar-${index}`,
|
||||
containerID: `containerd://${index}`,
|
||||
podName: 'app-demo-loadgenerator-579fbdd749-q5kgd',
|
||||
status: 'running',
|
||||
ready: true,
|
||||
restarts: index,
|
||||
image: 'ghcr.io/open-telemetry/demo:1.14.0-loadgenerator',
|
||||
cpuUsage: 0.12,
|
||||
memoryUsage: 148 * 1024 * 1024,
|
||||
meta: {
|
||||
'k8s.pod.name': 'app-demo-loadgenerator-579fbdd749-q5kgd',
|
||||
'k8s.namespace.name': 'generator',
|
||||
'k8s.cluster.name': 'mgmt',
|
||||
'k8s.node.name': 'gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-p6z7',
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export const overviewMocks = defineStoryMocks({
|
||||
controls: {
|
||||
containers: countControl('Containers', {
|
||||
group: 'Overview tab',
|
||||
value: 12,
|
||||
max: 40,
|
||||
}),
|
||||
},
|
||||
handlers: (values) => [
|
||||
// The filter's suggestions ask for these; without them the search box
|
||||
// logs a failed request and the story's smoke test fails on it
|
||||
rest.get('http://localhost/api/v1/fields/keys', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
|
||||
),
|
||||
),
|
||||
|
||||
rest.get('http://localhost/api/v1/fields/values', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
|
||||
),
|
||||
),
|
||||
|
||||
rest.post(
|
||||
'http://localhost/api/v2/infra_monitoring/kube_containers',
|
||||
async (req, res, ctx) => {
|
||||
const { offset = 0, limit = 10 } = (await req.json()) as {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: {
|
||||
type: 'list',
|
||||
records: buildContainers(values.containers).slice(
|
||||
offset,
|
||||
offset + limit,
|
||||
),
|
||||
total: values.containers,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import { GlobalTimeProvider } from 'store/globalTime';
|
||||
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../../constants';
|
||||
import EntityOverview, { EntityOverviewProps } from './EntityOverview';
|
||||
import { overviewMocks } from './EntityOverview.stories.mocks';
|
||||
|
||||
import drawerStyles from '../entityDetails.module.scss';
|
||||
|
||||
const POD_UID = '63edbad0-398e-48e5-baba-f7ded7c73f1b';
|
||||
|
||||
/** The meta /api/v2/infra_monitoring/pods returns for one pod. */
|
||||
const POD_ATTRIBUTES = {
|
||||
'k8s.pod.uid': POD_UID,
|
||||
'k8s.pod.name': 'app-demo-loadgenerator-579fbdd749-q5kgd',
|
||||
'k8s.namespace.name': 'generator',
|
||||
'k8s.cluster.name': 'mgmt',
|
||||
'k8s.node.name': 'gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-p6z7',
|
||||
'k8s.deployment.name': 'app-demo-loadgenerator',
|
||||
};
|
||||
|
||||
const route = `/infrastructure-monitoring/kubernetes?${INFRA_MONITORING_K8S_PARAMS_KEYS.CATEGORY}=pods&${INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM}=${POD_UID}`;
|
||||
|
||||
type OverviewArgs = PageStoryArgs<typeof overviewMocks> & EntityOverviewProps;
|
||||
|
||||
const meta = {
|
||||
title: 'Infra monitoring/Drawer overview tab',
|
||||
component: EntityOverview,
|
||||
...storyMocks(overviewMocks, { route }),
|
||||
decorators: [
|
||||
(Story): JSX.Element => (
|
||||
<DrawerWrapper
|
||||
open
|
||||
direction="right"
|
||||
title="app-demo-loadgenerator-579fbdd749-q5kgd"
|
||||
onOpenChange={(): void => undefined}
|
||||
className={drawerStyles.entityDetailDrawer}
|
||||
>
|
||||
<GlobalTimeProvider>
|
||||
<Story />
|
||||
</GlobalTimeProvider>
|
||||
</DrawerWrapper>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<OverviewArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<OverviewArgs>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
category: InfraMonitoringEntity.PODS,
|
||||
eventEntity: 'pod',
|
||||
attributes: POD_ATTRIBUTES,
|
||||
entity: { podUID: POD_UID },
|
||||
entityName: POD_ATTRIBUTES['k8s.pod.name'],
|
||||
queryKeyPrefix: 'pod',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@signozhq/ui/select';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
QuerySearchV2Provider,
|
||||
useExpression,
|
||||
useInputExpression,
|
||||
useQuerySearchInitialExpressionProp,
|
||||
useQuerySearchOnChange,
|
||||
useQuerySearchOnRun,
|
||||
useUserExpression,
|
||||
} from 'components/QueryBuilderV2';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
getUserExpressionFromCombined,
|
||||
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import { validateQuery } from 'utils/queryValidationUtils';
|
||||
|
||||
import { getEntityConfig } from '../../Base/entity.registry';
|
||||
import { logInfraExplorerNavigatedEvent } from '../../Base/events';
|
||||
import {
|
||||
buildRelatedFilterClauses,
|
||||
EntityAttributes,
|
||||
formatRelatedFilterClause,
|
||||
RelatedFilterClause,
|
||||
getEntityNameAttributeKey,
|
||||
getRelatedCategories,
|
||||
resolveRelatedCategory,
|
||||
} from '../../Base/relations';
|
||||
import { getDrawerDurationMs } from '../../Base/useDrawerLifecycleStore';
|
||||
import { buildK8sListNavigationUrl } from '../../Base/utils';
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
K8S_CATEGORY_LABELS,
|
||||
K8S_CATEGORY_SINGULAR_LABELS,
|
||||
METRIC_NAMESPACE_BY_ENTITY,
|
||||
VIEW_TYPES,
|
||||
} from '../../constants';
|
||||
import EntityDateTimeSelector from '../EntityDateTimeSelector/EntityDateTimeSelector';
|
||||
import { useEntityDetailsTime } from '../EntityDateTimeSelector/useEntityDetailsTime';
|
||||
import { logInfraDrawerFilterCustomizedEvent } from '../events';
|
||||
import { RelatedEntitiesTable } from './RelatedEntitiesTable';
|
||||
import { ScopeChip } from './ScopeChip';
|
||||
import { useCarriedOverFilters } from './useCarriedOverFilters';
|
||||
import { useRelatedEntities } from './useRelatedEntities';
|
||||
|
||||
import styles from './EntityOverview.module.scss';
|
||||
|
||||
export interface EntityOverviewProps {
|
||||
category: InfraMonitoringEntity;
|
||||
eventEntity: string;
|
||||
/** The drawer entity's own attributes, which scope every related query */
|
||||
attributes: EntityAttributes;
|
||||
/** The drawer's own record and query prefix, so a row click can leave a
|
||||
* trail that steps back without fetching again */
|
||||
entity: unknown;
|
||||
entityName: string;
|
||||
queryKeyPrefix: string;
|
||||
}
|
||||
|
||||
interface ContentProps extends EntityOverviewProps {
|
||||
targetCategory: InfraMonitoringEntity;
|
||||
lockedExpression: string;
|
||||
lockedClauses: RelatedFilterClause[];
|
||||
onCategoryChange: (category: string) => void;
|
||||
}
|
||||
|
||||
function EntityOverviewContent({
|
||||
category,
|
||||
eventEntity,
|
||||
attributes,
|
||||
entity,
|
||||
entityName,
|
||||
queryKeyPrefix,
|
||||
targetCategory,
|
||||
lockedExpression,
|
||||
lockedClauses,
|
||||
onCategoryChange,
|
||||
}: ContentProps): JSX.Element | null {
|
||||
const { timeRange } = useEntityDetailsTime();
|
||||
const expression = useExpression();
|
||||
const userExpression = useUserExpression();
|
||||
const inputExpression = useInputExpression();
|
||||
const querySearchOnChange = useQuerySearchOnChange();
|
||||
const querySearchOnRun = useQuerySearchOnRun();
|
||||
const querySearchInitialExpressionProp = useQuerySearchInitialExpressionProp();
|
||||
|
||||
const targetConfig = getEntityConfig(targetCategory);
|
||||
|
||||
useCarriedOverFilters(targetCategory);
|
||||
|
||||
const {
|
||||
records,
|
||||
total,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
endTimeBeforeRetention,
|
||||
setPage,
|
||||
setLimit,
|
||||
refetch,
|
||||
cancel,
|
||||
} = useRelatedEntities({
|
||||
fetchListData: targetConfig?.list.fetchListData,
|
||||
queryKey: `${targetCategory}RelatedEntities`,
|
||||
expression,
|
||||
timeRange,
|
||||
});
|
||||
|
||||
// The editor holds only what the user typed; the locked scope is shown beside it
|
||||
const queryData = useMemo(
|
||||
(): IBuilderQuery =>
|
||||
({
|
||||
aggregateOperator: 'noop',
|
||||
filter: { expression: userExpression },
|
||||
}) as IBuilderQuery,
|
||||
[userExpression],
|
||||
);
|
||||
|
||||
const handleCategoryChange = useCallback(
|
||||
(value: string | string[]): void => {
|
||||
if (typeof value === 'string') {
|
||||
onCategoryChange(value);
|
||||
}
|
||||
},
|
||||
[onCategoryChange],
|
||||
);
|
||||
|
||||
const handleRunQuery = useCallback(
|
||||
(updatedExpression?: string): void => {
|
||||
const newUserExpression = updatedExpression
|
||||
? getUserExpressionFromCombined(lockedExpression, updatedExpression)
|
||||
: inputExpression;
|
||||
|
||||
const validation = validateQuery(
|
||||
combineInitialAndUserExpression(lockedExpression, newUserExpression || ''),
|
||||
);
|
||||
|
||||
if (!validation.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
querySearchOnRun(newUserExpression);
|
||||
logInfraDrawerFilterCustomizedEvent(
|
||||
category,
|
||||
VIEW_TYPES.OVERVIEW,
|
||||
newUserExpression || '',
|
||||
'search',
|
||||
);
|
||||
refetch();
|
||||
},
|
||||
[lockedExpression, inputExpression, querySearchOnRun, category, refetch],
|
||||
);
|
||||
|
||||
const handleNavigate = useCallback((): void => {
|
||||
logInfraExplorerNavigatedEvent({
|
||||
entityType: category,
|
||||
destination: 'k8s_list',
|
||||
source: 'overview_cta',
|
||||
tab: VIEW_TYPES.OVERVIEW,
|
||||
sourceKey: targetCategory,
|
||||
drawerDurationMsAtNavigation: getDrawerDurationMs(),
|
||||
});
|
||||
|
||||
openInNewTab(buildK8sListNavigationUrl(targetCategory, expression));
|
||||
}, [category, targetCategory, expression]);
|
||||
|
||||
if (!targetConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetLabel = K8S_CATEGORY_LABELS[targetCategory];
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.controls}>
|
||||
<EntityDateTimeSelector
|
||||
eventEntity={eventEntity}
|
||||
category={category}
|
||||
view={VIEW_TYPES.OVERVIEW}
|
||||
/>
|
||||
|
||||
<RunQueryBtn
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
handleCancelQuery={cancel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterRow}>
|
||||
<ScopeChip clauses={lockedClauses} expression={lockedExpression} />
|
||||
|
||||
<div className={styles.filterInput}>
|
||||
<QuerySearch
|
||||
queryData={queryData}
|
||||
dataSource={DataSource.METRICS}
|
||||
onChange={querySearchOnChange}
|
||||
onRun={handleRunQuery}
|
||||
signalSource=""
|
||||
showFilterSuggestionsWithoutMetric
|
||||
placeholder={`Filter these ${targetLabel.toLowerCase()}`}
|
||||
metricNamespace={METRIC_NAMESPACE_BY_ENTITY[targetCategory]}
|
||||
initialExpression={querySearchInitialExpressionProp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RelatedEntitiesTable
|
||||
key={targetCategory}
|
||||
targetCategory={targetCategory}
|
||||
header={
|
||||
<div className={styles.resourcePicker}>
|
||||
<Typography.Text color="muted" size="small">
|
||||
Showing
|
||||
</Typography.Text>
|
||||
|
||||
<Select value={targetCategory} onChange={handleCategoryChange}>
|
||||
<SelectTrigger
|
||||
className={styles.resourceTrigger}
|
||||
data-testid="overview-resource-select"
|
||||
>
|
||||
<SelectValue>{targetLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
{/* The drawer sits at z-index 50 and select content ships without one */}
|
||||
<SelectContent className={styles.resourceOptions}>
|
||||
{getRelatedCategories(category, attributes).map((relatedCategory) => (
|
||||
<SelectItem
|
||||
key={relatedCategory}
|
||||
value={relatedCategory}
|
||||
testId={`overview-resource-option-${relatedCategory}`}
|
||||
>
|
||||
{K8S_CATEGORY_LABELS[relatedCategory]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<TooltipSimple
|
||||
title={`View on ${targetLabel.toLowerCase()} page`}
|
||||
side="top"
|
||||
arrow
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={handleNavigate}
|
||||
data-testid="overview-view-on-list-page"
|
||||
prefix={<Compass size={16} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
</div>
|
||||
}
|
||||
listConfig={targetConfig.list}
|
||||
records={records}
|
||||
total={total}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
endTimeBeforeRetention={endTimeBeforeRetention}
|
||||
source={{ category, label: entityName, entity, queryKeyPrefix }}
|
||||
onPageChange={setPage}
|
||||
onLimitChange={setLimit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EntityOverview(
|
||||
props: EntityOverviewProps,
|
||||
): JSX.Element | null {
|
||||
const { category, attributes } = props;
|
||||
|
||||
// Tied to the resource it was picked on, so another drawer starts at its
|
||||
// own default instead of inheriting the last pick
|
||||
const [selection, setSelection] = useState<{
|
||||
identity: string;
|
||||
category: string;
|
||||
} | null>(null);
|
||||
|
||||
const identity = `${category}:${
|
||||
attributes[getEntityNameAttributeKey(category)] ?? ''
|
||||
}`;
|
||||
|
||||
const targetCategory = resolveRelatedCategory(
|
||||
category,
|
||||
attributes,
|
||||
selection?.identity === identity ? selection.category : null,
|
||||
);
|
||||
|
||||
const handleCategoryChange = useCallback(
|
||||
(value: string): void => setSelection({ identity, category: value }),
|
||||
[identity],
|
||||
);
|
||||
|
||||
const lockedClauses = targetCategory
|
||||
? buildRelatedFilterClauses(category, targetCategory, attributes)
|
||||
: [];
|
||||
const lockedExpression = lockedClauses
|
||||
.map(formatRelatedFilterClause)
|
||||
.join(' AND ');
|
||||
|
||||
if (!targetCategory) {
|
||||
return (
|
||||
<div className={styles.noRelations}>
|
||||
<Typography.Text color="muted">
|
||||
{`Nothing related to this ${K8S_CATEGORY_SINGULAR_LABELS[category]} was reported in this time range.`}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QuerySearchV2Provider
|
||||
queryParamKey={INFRA_MONITORING_K8S_PARAMS_KEYS.OVERVIEW_EXPRESSION}
|
||||
initialExpression={lockedExpression}
|
||||
>
|
||||
<EntityOverviewContent
|
||||
{...props}
|
||||
targetCategory={targetCategory}
|
||||
lockedExpression={lockedExpression}
|
||||
lockedClauses={lockedClauses}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
/>
|
||||
</QuerySearchV2Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { ReactNode, useCallback } from 'react';
|
||||
import cx from 'classnames';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import {
|
||||
K8sEntityData,
|
||||
K8sEntityListConfig,
|
||||
} from '../../Base/entity.config.types';
|
||||
import { K8sEmptyState } from '../../Base/K8sEmptyState';
|
||||
import {
|
||||
DrawerHistoryEntry,
|
||||
useDrawerHistoryStore,
|
||||
} from '../../Base/useDrawerHistoryStore';
|
||||
import {
|
||||
useInfraMonitoringFontSize,
|
||||
useInfraMonitoringLineClamp,
|
||||
} from '../../Base/useInfraMonitoringTablePreferencesStore';
|
||||
import { InfraMonitoringEntity, K8S_CATEGORY_LABELS } from '../../constants';
|
||||
import {
|
||||
SelectedItemParams,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
} from '../../hooks';
|
||||
import { usePrimeEntityDetails } from '../../Base/usePrimeEntityDetails';
|
||||
import { RELATED_ENTITIES_PAGE_SIZE } from './useRelatedEntities';
|
||||
|
||||
import listStyles from '../../Base/K8sBaseList.module.scss';
|
||||
import styles from './EntityOverview.module.scss';
|
||||
|
||||
interface RelatedEntitiesTableProps {
|
||||
targetCategory: InfraMonitoringEntity;
|
||||
listConfig: K8sEntityListConfig<K8sEntityData, string | SelectedItemParams>;
|
||||
records: K8sEntityData[];
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error?: APIError | null;
|
||||
endTimeBeforeRetention?: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
onLimitChange: (limit: number) => void;
|
||||
/** Rendered in the card's header strip, above the rows it describes */
|
||||
header: ReactNode;
|
||||
/** The drawer's own resource, so a row click can leave a trail back to it */
|
||||
source: Omit<DrawerHistoryEntry, 'params'>;
|
||||
}
|
||||
|
||||
export function RelatedEntitiesTable({
|
||||
targetCategory,
|
||||
listConfig,
|
||||
records,
|
||||
total,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
endTimeBeforeRetention,
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
header,
|
||||
source,
|
||||
}: RelatedEntitiesTableProps): JSX.Element {
|
||||
const [selectedItemParams, setSelectedItemParams] =
|
||||
useInfraMonitoringSelectedItemParams();
|
||||
const pushDrawerHistory = useDrawerHistoryStore((store) => store.push);
|
||||
const primeDetails = usePrimeEntityDetails();
|
||||
const lineClamp = useInfraMonitoringLineClamp();
|
||||
const fontSize = useInfraMonitoringFontSize();
|
||||
|
||||
// Opens the clicked resource in its own drawer, overview tab included
|
||||
const handleRowClick = useCallback(
|
||||
(record: K8sEntityData, itemKey: string | SelectedItemParams): void => {
|
||||
const params: SelectedItemParams =
|
||||
typeof itemKey === 'object'
|
||||
? itemKey
|
||||
: { selectedItem: itemKey, clusterName: null, namespaceName: null };
|
||||
|
||||
pushDrawerHistory({ ...source, params: selectedItemParams });
|
||||
|
||||
// The row already carries the record the drawer is about to fetch
|
||||
primeDetails({
|
||||
params,
|
||||
queryKeyPrefix: listConfig.detailsQueryKeyPrefix,
|
||||
entity: record,
|
||||
});
|
||||
setSelectedItemParams({ ...params, category: targetCategory });
|
||||
},
|
||||
[
|
||||
primeDetails,
|
||||
pushDrawerHistory,
|
||||
selectedItemParams,
|
||||
source,
|
||||
setSelectedItemParams,
|
||||
targetCategory,
|
||||
listConfig.detailsQueryKeyPrefix,
|
||||
],
|
||||
);
|
||||
|
||||
const isEmpty = !isLoading && records.length === 0;
|
||||
|
||||
return (
|
||||
<div className={styles.tableCard}>
|
||||
<div className={styles.tableHeader}>{header}</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className={styles.emptyState}>
|
||||
<K8sEmptyState
|
||||
isError={isError}
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
endTimeBeforeRetention={endTimeBeforeRetention}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<TanStackTable<K8sEntityData, string | SelectedItemParams>
|
||||
data={records}
|
||||
columns={listConfig.tableColumns}
|
||||
columnStorageKey={`k8s-overview-${targetCategory}-columns`}
|
||||
isLoading={isLoading}
|
||||
getRowKey={listConfig.getRowKey}
|
||||
getItemKey={listConfig.getItemKey}
|
||||
onRowClick={handleRowClick}
|
||||
className={cx(listStyles.k8SListTable, styles.table)}
|
||||
testId={`related-entities-table-${targetCategory}`}
|
||||
// A page of rows is short enough to render whole, which keeps the card
|
||||
// at the height of its rows instead of stretching down the drawer
|
||||
disableVirtualScroll
|
||||
pagination={{
|
||||
total,
|
||||
defaultLimit: RELATED_ENTITIES_PAGE_SIZE,
|
||||
showTotalCount: true,
|
||||
totalCountLabel: K8S_CATEGORY_LABELS[targetCategory],
|
||||
onPageChange,
|
||||
onLimitChange,
|
||||
}}
|
||||
plainTextCellLineClamp={lineClamp}
|
||||
cellTypographySize={fontSize}
|
||||
paginationClassname={listStyles.paginationContainer}
|
||||
resetScrollKey={targetCategory}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Lock } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
|
||||
import { RelatedFilterClause } from '../../Base/relations';
|
||||
|
||||
import styles from './EntityOverview.module.scss';
|
||||
|
||||
interface ScopeChipProps {
|
||||
clauses: RelatedFilterClause[];
|
||||
/** The clauses as one expression, shown in full on hover */
|
||||
expression: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The locked half of the filter. It reads like the editor beside it: the last
|
||||
* clause carries the relation, the ones scoping it are counted instead.
|
||||
*/
|
||||
export function ScopeChip({
|
||||
clauses,
|
||||
expression,
|
||||
}: ScopeChipProps): JSX.Element {
|
||||
const relation = clauses[clauses.length - 1];
|
||||
|
||||
return (
|
||||
<TooltipSimple title={expression} side="top" arrow>
|
||||
<span className={styles.scopeChip} data-testid="overview-locked-scope">
|
||||
<Lock size={12} />
|
||||
<span className={styles.scopeExpression}>
|
||||
<span className={styles.scopeKey}>{relation.key}</span>
|
||||
<span className={styles.scopeOperator}> = </span>
|
||||
<span className={styles.scopeValue}>
|
||||
{formatValueForExpression(relation.value)}
|
||||
</span>
|
||||
</span>
|
||||
{clauses.length > 1 && (
|
||||
<span className={styles.scopeMore}>{`+${clauses.length - 1}`}</span>
|
||||
)}
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { NuqsTestingAdapter, OnUrlUpdateFunction } from 'nuqs/adapters/testing';
|
||||
|
||||
import { TableColumnDef } from 'components/TanStackTableView';
|
||||
|
||||
import { InfraMonitoringEntity } from '../../../constants';
|
||||
import EntityOverview from '../EntityOverview';
|
||||
|
||||
window.ResizeObserver =
|
||||
window.ResizeObserver ||
|
||||
jest.fn().mockImplementation(() => ({
|
||||
disconnect: jest.fn(),
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="mock-datetime" />,
|
||||
}));
|
||||
|
||||
const mockQuerySearchProps = jest.fn();
|
||||
|
||||
jest.mock('components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch', () => ({
|
||||
__esModule: true,
|
||||
default: (props: {
|
||||
queryData?: { filter?: { expression?: string } };
|
||||
initialExpression?: string;
|
||||
}): JSX.Element => {
|
||||
mockQuerySearchProps(props);
|
||||
return <div data-testid="mock-query-search" />;
|
||||
},
|
||||
}));
|
||||
|
||||
type TestRecord = { name: string; meta?: Record<string, string> | null };
|
||||
|
||||
const mockFetchListData = jest.fn();
|
||||
|
||||
const mockTableColumns: TableColumnDef<TestRecord>[] = [
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
accessorKey: 'name',
|
||||
cell: ({ value }): string => String(value),
|
||||
},
|
||||
];
|
||||
|
||||
jest.mock('../../../Base/entity.registry', () => ({
|
||||
getEntityConfig: (category: string) => ({
|
||||
list: {
|
||||
entity: category,
|
||||
tableColumns: mockTableColumns,
|
||||
fetchListData: mockFetchListData,
|
||||
getRowKey: (record: TestRecord): string => record.name,
|
||||
getItemKey: (record: TestRecord): string => record.name,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const NODE_ATTRIBUTES = {
|
||||
'k8s.node.name': 'node-1',
|
||||
'k8s.cluster.name': 'prod',
|
||||
};
|
||||
|
||||
const LOCKED_EXPRESSION =
|
||||
"k8s.cluster.name = 'prod' AND k8s.node.name = 'node-1'";
|
||||
|
||||
interface RenderOptions {
|
||||
onUrlUpdate?: OnUrlUpdateFunction;
|
||||
attributes?: Record<string, string>;
|
||||
}
|
||||
|
||||
function overviewTree(
|
||||
attributes: Record<string, string>,
|
||||
queryClient: QueryClient,
|
||||
onUrlUpdate?: OnUrlUpdateFunction,
|
||||
): JSX.Element {
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NuqsTestingAdapter onUrlUpdate={onUrlUpdate}>
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 800, itemHeight: 50 }}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<EntityOverview
|
||||
category={InfraMonitoringEntity.NODES}
|
||||
eventEntity="node"
|
||||
attributes={attributes}
|
||||
entity={{ nodeName: attributes['k8s.node.name'] }}
|
||||
entityName={attributes['k8s.node.name']}
|
||||
queryKeyPrefix="node"
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</VirtuosoMockContext.Provider>
|
||||
</NuqsTestingAdapter>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function renderOverview({
|
||||
onUrlUpdate,
|
||||
attributes = NODE_ATTRIBUTES,
|
||||
}: RenderOptions = {}): {
|
||||
openAnotherResource: (next: Record<string, string>) => void;
|
||||
} {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
overviewTree(attributes, queryClient, onUrlUpdate),
|
||||
);
|
||||
|
||||
return {
|
||||
openAnotherResource: (next): void =>
|
||||
rerender(overviewTree(next, queryClient, onUrlUpdate)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('EntityOverview', () => {
|
||||
beforeEach(() => {
|
||||
mockQuerySearchProps.mockReset();
|
||||
mockFetchListData.mockReset();
|
||||
mockFetchListData.mockResolvedValue({
|
||||
type: 'list',
|
||||
records: [{ name: 'pod-1' }],
|
||||
total: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('lists the first related resource scoped by the locked expression', async () => {
|
||||
renderOverview();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchListData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockFetchListData.mock.calls[0][0].filter.expression).toBe(
|
||||
LOCKED_EXPRESSION,
|
||||
);
|
||||
await expect(
|
||||
screen.findByTestId('related-entities-table-pods'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the locked scope out of the editable filter', async () => {
|
||||
renderOverview();
|
||||
|
||||
// The chip leads with the relation and counts the scope behind it
|
||||
const chip = await screen.findByTestId('overview-locked-scope');
|
||||
expect(chip).toHaveTextContent("k8s.node.name = 'node-1'");
|
||||
expect(chip).toHaveTextContent('+1');
|
||||
|
||||
// The editor is handed the user's own expression, never the locked scope
|
||||
const lastProps = mockQuerySearchProps.mock.calls.at(-1)?.[0];
|
||||
expect(lastProps.queryData.filter.expression).toBe('');
|
||||
expect(lastProps.initialExpression).toBe(LOCKED_EXPRESSION);
|
||||
});
|
||||
|
||||
it('opens the clicked resource in its own drawer', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUrlUpdate = jest.fn();
|
||||
|
||||
renderOverview({ onUrlUpdate });
|
||||
|
||||
const row = await screen.findByText('pod-1');
|
||||
await user.click(row);
|
||||
|
||||
await waitFor(() => {
|
||||
const params = onUrlUpdate.mock.calls.map((call) => call[0].searchParams);
|
||||
expect(
|
||||
params.some(
|
||||
(searchParams) =>
|
||||
searchParams.get('selectedItem') === 'pod-1' &&
|
||||
searchParams.get('selectedItemCategory') === 'pods',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("forgets the previous drawer's resource when another one opens", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const { openAnotherResource } = renderOverview();
|
||||
|
||||
await user.click(screen.getByTestId('overview-resource-select'));
|
||||
await user.click(
|
||||
await screen.findByTestId('overview-resource-option-containers'),
|
||||
);
|
||||
await expect(
|
||||
screen.findByTestId('related-entities-table-containers'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
openAnotherResource({ ...NODE_ATTRIBUTES, 'k8s.node.name': 'node-2' });
|
||||
|
||||
// Back to pods, this node's default, rather than the containers just picked
|
||||
await expect(
|
||||
screen.findByTestId('related-entities-table-pods'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says so when the resource relates to nothing', async () => {
|
||||
renderOverview({ attributes: { 'os.type': 'linux' } });
|
||||
|
||||
await expect(
|
||||
screen.findByText(/Nothing related to this Node/),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(mockFetchListData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('switches the listed resource from the dropdown', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderOverview();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchListData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await user.click(screen.getByTestId('overview-resource-select'));
|
||||
|
||||
// Every resource related to a node is offered, the listed one included
|
||||
await expect(
|
||||
screen.findByTestId('overview-resource-option-pods'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
await screen.findByTestId('overview-resource-option-containers'),
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('related-entities-table-containers'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQuerySearchOnRun } from 'components/QueryBuilderV2';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
|
||||
import { carryOverListFilters } from '../../Base/relations';
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../../constants';
|
||||
|
||||
/**
|
||||
* Seeds the editable half of the filter with whatever the list page was
|
||||
* filtered by, reduced to the clauses the listed resource understands. Runs
|
||||
* once per resource, so it never overwrites what the user types, and stands
|
||||
* aside for a drawer opened from a link that already carries a filter.
|
||||
*/
|
||||
export function useCarriedOverFilters(
|
||||
targetCategory: InfraMonitoringEntity,
|
||||
): void {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const listExpression =
|
||||
currentQuery.builder.queryData[0]?.filter?.expression || '';
|
||||
const querySearchOnRun = useQuerySearchOnRun();
|
||||
|
||||
const [urlExpression] = useQueryState(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.OVERVIEW_EXPRESSION,
|
||||
parseAsString,
|
||||
);
|
||||
|
||||
const seededFor = useRef<string | null>(null);
|
||||
const hadUrlExpressionOnMount = useRef<boolean | null>(null);
|
||||
if (hadUrlExpressionOnMount.current === null) {
|
||||
hadUrlExpressionOnMount.current = !!urlExpression;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (seededFor.current === targetCategory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstResource = seededFor.current === null;
|
||||
seededFor.current = targetCategory;
|
||||
|
||||
if (isFirstResource && hadUrlExpressionOnMount.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
querySearchOnRun(carryOverListFilters(listExpression, targetCategory));
|
||||
}, [targetCategory, listExpression, querySearchOnRun]);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery, useQueryClient } from 'react-query';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
import {
|
||||
K8sEntityData,
|
||||
K8sEntityListConfig,
|
||||
} from '../../Base/entity.config.types';
|
||||
import { SelectedItemParams } from '../../hooks';
|
||||
|
||||
type FetchRelatedEntities = K8sEntityListConfig<
|
||||
K8sEntityData,
|
||||
string | SelectedItemParams
|
||||
>['fetchListData'];
|
||||
|
||||
interface UseRelatedEntitiesParams {
|
||||
/** Absent while the target category is still unresolved; the query stays idle */
|
||||
fetchListData?: FetchRelatedEntities;
|
||||
queryKey: string;
|
||||
expression: string;
|
||||
/** Time range in seconds — see useEntityDetailsTime */
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
}
|
||||
|
||||
interface UseRelatedEntitiesResult {
|
||||
records: K8sEntityData[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
setPage: (page: number) => void;
|
||||
setLimit: (limit: number) => void;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
error?: APIError | null;
|
||||
endTimeBeforeRetention?: boolean;
|
||||
refetch: () => void;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export const RELATED_ENTITIES_PAGE_SIZE = 10;
|
||||
|
||||
const SECOND_IN_MS = 1000;
|
||||
|
||||
export function useRelatedEntities({
|
||||
fetchListData,
|
||||
queryKey,
|
||||
expression,
|
||||
timeRange,
|
||||
}: UseRelatedEntitiesParams): UseRelatedEntitiesResult {
|
||||
const queryClient = useQueryClient();
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit, setLimit] = useState(RELATED_ENTITIES_PAGE_SIZE);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [expression, queryKey]);
|
||||
|
||||
const reactQueryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
|
||||
queryKey,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
expression,
|
||||
page,
|
||||
limit,
|
||||
],
|
||||
[queryKey, timeRange.startTime, timeRange.endTime, expression, page, limit],
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: reactQueryKey,
|
||||
queryFn: ({ signal }) =>
|
||||
(fetchListData as FetchRelatedEntities)(
|
||||
{
|
||||
filter: { expression },
|
||||
offset: (page - 1) * limit,
|
||||
limit,
|
||||
// The list APIs take milliseconds, useEntityDetailsTime gives seconds
|
||||
start: timeRange.startTime * SECOND_IN_MS,
|
||||
end: timeRange.endTime * SECOND_IN_MS,
|
||||
},
|
||||
signal,
|
||||
),
|
||||
enabled: !!fetchListData && !!expression.trim(),
|
||||
});
|
||||
|
||||
const cancel = useCallback((): void => {
|
||||
void queryClient.cancelQueries({ queryKey: reactQueryKey });
|
||||
}, [queryClient, reactQueryKey]);
|
||||
|
||||
const handleRefetch = useCallback((): void => {
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
return {
|
||||
records: data?.records ?? data?.data ?? [],
|
||||
total: data?.total ?? 0,
|
||||
page,
|
||||
limit,
|
||||
setPage,
|
||||
setLimit,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
error: data?.error,
|
||||
endTimeBeforeRetention: data?.endTimeBeforeRetention,
|
||||
refetch: handleRefetch,
|
||||
cancel,
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Container } from '@signozhq/icons';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
|
||||
import { CustomTab } from '../Base/K8sBaseDetails';
|
||||
import { CustomTab } from '../Base/types';
|
||||
import {
|
||||
InfraMonitoringEntity,
|
||||
podUtilizationByPodWidgetInfo,
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-8);
|
||||
margin: 0;
|
||||
min-width: 900px;
|
||||
// On a 13" screen the drawer itself is narrower than 900px, and a flat
|
||||
// minimum there clips the right edge of every tab
|
||||
box-sizing: border-box;
|
||||
min-width: min(900px, 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +37,29 @@
|
||||
--button-padding: var(--spacing-2);
|
||||
}
|
||||
|
||||
.backButton {
|
||||
--button-padding: var(--spacing-2) var(--spacing-3);
|
||||
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// The kind reads as a label on the name, not as part of it
|
||||
.entityKind {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
flex: none;
|
||||
padding: var(--spacing-1) var(--spacing-3);
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
color: var(--l2-foreground);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: var(--periscope-font-family-mono);
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import type {
|
||||
InfraMonitoringEntity,
|
||||
VIEWS,
|
||||
} from 'container/InfraMonitoringK8sV2/constants';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
import { isCustomTimeRange } from 'store/globalTime';
|
||||
@@ -15,7 +18,7 @@ export function logInfraDrawerTimeRangeCustomizedEvent(
|
||||
|
||||
export function logInfraDrawerFilterCustomizedEvent(
|
||||
entityType: InfraMonitoringEntity,
|
||||
tab: 'metrics' | 'logs' | 'traces' | 'events' | 'pod_metrics',
|
||||
tab: `${VIEWS}`,
|
||||
expression: string,
|
||||
filterSource: 'search' | 'logs',
|
||||
): void {
|
||||
|
||||
@@ -12,23 +12,11 @@ import {
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
ArrowUpDown,
|
||||
ArrowUpToLine,
|
||||
Bolt,
|
||||
Box,
|
||||
Boxes,
|
||||
Computer,
|
||||
Container,
|
||||
FilePenLine,
|
||||
Filter,
|
||||
Group,
|
||||
HardDrive,
|
||||
Workflow,
|
||||
} from '@signozhq/icons';
|
||||
import { ArrowUpToLine, Filter } from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { CategoryIcon } from './Base/categoryIcons';
|
||||
import { K8sDynamicList } from './Base/K8sDynamicList';
|
||||
import {
|
||||
GetClustersQuickFiltersConfig,
|
||||
@@ -42,6 +30,7 @@ import {
|
||||
GetStatefulsetsQuickFiltersConfig,
|
||||
GetVolumesQuickFiltersConfig,
|
||||
InfraMonitoringEntity,
|
||||
K8S_CATEGORY_LABELS,
|
||||
K8sCategories,
|
||||
METRIC_NAMESPACE_BY_ENTITY,
|
||||
} from './constants';
|
||||
@@ -156,62 +145,62 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
() => [
|
||||
{
|
||||
key: K8sCategories.CONTAINERS,
|
||||
label: 'Containers',
|
||||
icon: <Box size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.CONTAINERS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.CONTAINERS} />,
|
||||
config: GetContainersQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.PODS,
|
||||
label: 'Pods',
|
||||
icon: <Container size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.PODS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.PODS} />,
|
||||
config: GetPodsQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.NODES,
|
||||
label: 'Nodes',
|
||||
icon: <Workflow size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.NODES],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.NODES} />,
|
||||
config: GetNodesQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.NAMESPACES,
|
||||
label: 'Namespaces',
|
||||
icon: <FilePenLine size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.NAMESPACES],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.NAMESPACES} />,
|
||||
config: GetNamespaceQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.CLUSTERS,
|
||||
label: 'Clusters',
|
||||
icon: <Boxes size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.CLUSTERS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.CLUSTERS} />,
|
||||
config: GetClustersQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.DEPLOYMENTS,
|
||||
label: 'Deployments',
|
||||
icon: <Computer size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.DEPLOYMENTS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.DEPLOYMENTS} />,
|
||||
config: GetDeploymentsQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.JOBS,
|
||||
label: 'Jobs',
|
||||
icon: <Bolt size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.JOBS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.JOBS} />,
|
||||
config: GetJobsQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.DAEMONSETS,
|
||||
label: 'DaemonSets',
|
||||
icon: <Group size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.DAEMONSETS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.DAEMONSETS} />,
|
||||
config: GetDaemonsetsQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.STATEFULSETS,
|
||||
label: 'StatefulSets',
|
||||
icon: <ArrowUpDown size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.STATEFULSETS],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.STATEFULSETS} />,
|
||||
config: GetStatefulsetsQuickFiltersConfig(),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.VOLUMES,
|
||||
label: 'Volumes',
|
||||
icon: <HardDrive size={14} />,
|
||||
label: K8S_CATEGORY_LABELS[InfraMonitoringEntity.VOLUMES],
|
||||
icon: <CategoryIcon category={InfraMonitoringEntity.VOLUMES} />,
|
||||
config: GetVolumesQuickFiltersConfig(),
|
||||
},
|
||||
],
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import {
|
||||
getPodUtilizationByPodQueryPayloads,
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
|
||||
@@ -132,6 +132,7 @@ export const jobEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.JOBS,
|
||||
eventCategory: InfraMonitoringEvents.Job,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'job',
|
||||
getSelectedItemExpression: k8sJobGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -6,10 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import {
|
||||
K8sDetailsCountConfig,
|
||||
K8sDetailsMetadataConfig,
|
||||
} from '../Base/K8sBaseDetails';
|
||||
import { K8sDetailsCountConfig, K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import {
|
||||
getPodUtilizationByPodQueryPayloads,
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
|
||||
@@ -143,6 +143,7 @@ export const namespaceEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.NAMESPACES,
|
||||
eventCategory: InfraMonitoringEvents.Namespace,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'namespace',
|
||||
getSelectedItemExpression: k8sNamespaceGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
buildLogsTracesExpression,
|
||||
} from 'container/InfraMonitoringK8sV2/Base/utils';
|
||||
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ export const nodeEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.NODES,
|
||||
eventCategory: InfraMonitoringEvents.Node,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'node',
|
||||
getSelectedItemExpression: k8sNodeGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import { formatValueForExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
buildEventsExpression,
|
||||
|
||||
@@ -121,6 +121,7 @@ export const podEntityConfig: K8sEntityConfig<InframonitoringtypesPodRecordDTO>
|
||||
details: {
|
||||
category: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'pod',
|
||||
getSelectedItemExpression: k8sPodGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import {
|
||||
getPodUtilizationByPodQueryPayloads,
|
||||
INFRA_MONITORING_ATTR_KEYS,
|
||||
|
||||
@@ -132,6 +132,7 @@ export const statefulSetEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.STATEFULSETS,
|
||||
eventCategory: InfraMonitoringEvents.StatefulSet,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'statefulSet',
|
||||
getSelectedItemExpression: k8sStatefulSetGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { K8sDetailsMetadataConfig } from '../Base/K8sBaseDetails';
|
||||
import { K8sDetailsMetadataConfig } from '../Base/types';
|
||||
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
|
||||
import { SelectedItemParams } from '../hooks';
|
||||
import {
|
||||
|
||||
@@ -127,6 +127,7 @@ export const volumeEntityConfig: K8sEntityConfig<
|
||||
details: {
|
||||
category: InfraMonitoringEntity.VOLUMES,
|
||||
eventCategory: InfraMonitoringEvents.Volume,
|
||||
tabsConfig: { showOverview: true },
|
||||
queryKeyPrefix: 'volume',
|
||||
getSelectedItemExpression: k8sVolumeGetSelectedItemExpression,
|
||||
fetchEntityData,
|
||||
|
||||
@@ -157,9 +157,11 @@ export enum VIEWS {
|
||||
PROCESSES = 'processes',
|
||||
EVENTS = 'events',
|
||||
POD_METRICS = 'pod_metrics',
|
||||
OVERVIEW = 'overview',
|
||||
}
|
||||
|
||||
export const VIEW_TYPES = {
|
||||
OVERVIEW: VIEWS.OVERVIEW,
|
||||
METRICS: VIEWS.METRICS,
|
||||
LOGS: VIEWS.LOGS,
|
||||
TRACES: VIEWS.TRACES,
|
||||
@@ -896,6 +898,41 @@ export const getInvalidValueTooltipText = (
|
||||
attribute: string,
|
||||
): string => `Some ${entity} do not have ${attribute}s.`;
|
||||
|
||||
export const K8S_CATEGORY_LABELS: Record<InfraMonitoringEntity, string> = {
|
||||
[InfraMonitoringEntity.HOSTS]: 'Hosts',
|
||||
[InfraMonitoringEntity.PODS]: 'Pods',
|
||||
[InfraMonitoringEntity.NODES]: 'Nodes',
|
||||
[InfraMonitoringEntity.NAMESPACES]: 'Namespaces',
|
||||
[InfraMonitoringEntity.CLUSTERS]: 'Clusters',
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]: 'Deployments',
|
||||
[InfraMonitoringEntity.STATEFULSETS]: 'StatefulSets',
|
||||
[InfraMonitoringEntity.DAEMONSETS]: 'DaemonSets',
|
||||
[InfraMonitoringEntity.CONTAINERS]: 'Containers',
|
||||
[InfraMonitoringEntity.JOBS]: 'Jobs',
|
||||
[InfraMonitoringEntity.VOLUMES]: 'Volumes',
|
||||
};
|
||||
|
||||
/**
|
||||
* Used where one record is named, like the drawer's back control. Cased the
|
||||
* same way as the plural labels, so StatefulSet keeps its capital.
|
||||
*/
|
||||
export const K8S_CATEGORY_SINGULAR_LABELS: Record<
|
||||
InfraMonitoringEntity,
|
||||
string
|
||||
> = {
|
||||
[InfraMonitoringEntity.HOSTS]: 'Host',
|
||||
[InfraMonitoringEntity.PODS]: 'Pod',
|
||||
[InfraMonitoringEntity.NODES]: 'Node',
|
||||
[InfraMonitoringEntity.NAMESPACES]: 'Namespace',
|
||||
[InfraMonitoringEntity.CLUSTERS]: 'Cluster',
|
||||
[InfraMonitoringEntity.DEPLOYMENTS]: 'Deployment',
|
||||
[InfraMonitoringEntity.STATEFULSETS]: 'StatefulSet',
|
||||
[InfraMonitoringEntity.DAEMONSETS]: 'DaemonSet',
|
||||
[InfraMonitoringEntity.CONTAINERS]: 'Container',
|
||||
[InfraMonitoringEntity.JOBS]: 'Job',
|
||||
[InfraMonitoringEntity.VOLUMES]: 'Volume',
|
||||
};
|
||||
|
||||
export const ENTITY_FILTER_PLACEHOLDERS: Record<InfraMonitoringEntity, string> =
|
||||
{
|
||||
[InfraMonitoringEntity.HOSTS]:
|
||||
@@ -939,9 +976,11 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
|
||||
PAGE_SIZE: 'pageSize',
|
||||
EXPANDED: 'expanded',
|
||||
SELECTED_ITEM: 'selectedItem',
|
||||
SELECTED_ITEM_CATEGORY: 'selectedItemCategory',
|
||||
SELECTED_ITEM_CLUSTER_NAME: 'selectedItemClusterName',
|
||||
SELECTED_ITEM_NAMESPACE_NAME: 'selectedItemNamespaceName',
|
||||
SELECTED_ITEM_CONTAINER_NAME: 'selectedItemContainerName',
|
||||
OVERVIEW_EXPRESSION: 'overviewExpression',
|
||||
DETAIL_RELATIVE_TIME: 'detailRelativeTime',
|
||||
DETAIL_START_TIME: 'detailStartTime',
|
||||
DETAIL_END_TIME: 'detailEndTime',
|
||||
|
||||
@@ -18,7 +18,6 @@ import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
import {
|
||||
DEFAULT_K8S_CATEGORY,
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
VIEWS,
|
||||
} from './constants';
|
||||
import { orderBySchema, OrderBySchemaType } from './schemas';
|
||||
|
||||
@@ -85,10 +84,13 @@ export const useInfraMonitoringGroupBy = (): UseQueryStateReturn<
|
||||
parseAsGroupBy.withDefault([]).withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export const useInfraMonitoringView = (): UseQueryStateReturn<string, string> =>
|
||||
export const useInfraMonitoringView = (): UseQueryStateReturn<
|
||||
string | null,
|
||||
undefined
|
||||
> =>
|
||||
useQueryState(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW,
|
||||
parseAsString.withDefault(VIEWS.METRICS).withOptions(defaultNuqsOptions),
|
||||
parseAsString.withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export const useInfraMonitoringLogFilters = (): UseQueryStateReturn<
|
||||
@@ -137,6 +139,8 @@ export const useInfraMonitoringCategory = (): UseQueryStateReturn<
|
||||
|
||||
export interface SelectedItemParams {
|
||||
selectedItem: string | null;
|
||||
/** Drawer category, when it differs from the list page category. */
|
||||
category?: string | null;
|
||||
clusterName?: string | null;
|
||||
namespaceName?: string | null;
|
||||
containerName?: string | null;
|
||||
@@ -144,6 +148,7 @@ export interface SelectedItemParams {
|
||||
|
||||
const selectedItemParamsParsers = {
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: parseAsString,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY]: parseAsString,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: parseAsString,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: parseAsString,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: parseAsString,
|
||||
@@ -165,6 +170,8 @@ export const useInfraMonitoringSelectedItemParams =
|
||||
() => ({
|
||||
selectedItem:
|
||||
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM] ?? null,
|
||||
category:
|
||||
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY] ?? null,
|
||||
clusterName:
|
||||
rawParams[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME] ??
|
||||
null,
|
||||
@@ -183,6 +190,7 @@ export const useInfraMonitoringSelectedItemParams =
|
||||
if (newParams === null) {
|
||||
void setRawParams({
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]: null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY]: null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]: null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]: null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CONTAINER_NAME]: null,
|
||||
@@ -193,6 +201,8 @@ export const useInfraMonitoringSelectedItemParams =
|
||||
void setRawParams({
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM]:
|
||||
newParams.selectedItem ?? null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CATEGORY]:
|
||||
newParams.category ?? null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_CLUSTER_NAME]:
|
||||
newParams.clusterName ?? null,
|
||||
[INFRA_MONITORING_K8S_PARAMS_KEYS.SELECTED_ITEM_NAMESPACE_NAME]:
|
||||
|
||||
@@ -24,6 +24,7 @@ export default function Legend({
|
||||
focusedSeriesIndex,
|
||||
onAction,
|
||||
showCopy = true,
|
||||
itemLabel = 'series',
|
||||
}: LegendProps): JSX.Element {
|
||||
const legendContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
@@ -92,11 +93,12 @@ export default function Legend({
|
||||
position={position}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
itemLabel={itemLabel}
|
||||
/>
|
||||
)}
|
||||
{isEmptyState ? (
|
||||
<div className={styles.emptyState}>
|
||||
No series found matching "{effectiveQuery}"
|
||||
No {itemLabel} found matching "{effectiveQuery}"
|
||||
</div>
|
||||
) : (
|
||||
<VirtuosoGrid
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface LegendToolbarProps {
|
||||
position: LegendPosition;
|
||||
filterQuery: string;
|
||||
onFilterQueryChange: (query: string) => void;
|
||||
itemLabel: string;
|
||||
}
|
||||
|
||||
/** Legend chrome: the series search box and the "Showing N of M" readout. */
|
||||
@@ -23,6 +24,7 @@ export default function LegendToolbar({
|
||||
position,
|
||||
filterQuery,
|
||||
onFilterQueryChange,
|
||||
itemLabel,
|
||||
}: LegendToolbarProps): JSX.Element {
|
||||
const handleFilterChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>): void =>
|
||||
@@ -48,7 +50,7 @@ export default function LegendToolbar({
|
||||
aria-live="polite"
|
||||
data-testid="legend-status"
|
||||
>
|
||||
{`Showing ${visibleCount} of ${totalCount} series`}
|
||||
{`Showing ${visibleCount} of ${totalCount} ${itemLabel}`}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
@@ -168,6 +168,8 @@ export interface LegendProps {
|
||||
onAction: OnLegendAction;
|
||||
/** Show the per-item copy button. Default true. */
|
||||
showCopy?: boolean;
|
||||
/** Plural noun for the items in the search readouts. Default 'series'. */
|
||||
itemLabel?: string;
|
||||
}
|
||||
|
||||
/** Props for the uPlot legend controller, which derives items + interaction
|
||||
|
||||
@@ -63,20 +63,20 @@ export default function ChartWrapper({
|
||||
);
|
||||
|
||||
const legendComponent = useCallback(
|
||||
({ averageLegendWidth, showSearch }: LegendLayout): React.ReactNode => {
|
||||
(layout: LegendLayout): React.ReactNode => {
|
||||
if (!showLegend) {
|
||||
return null;
|
||||
}
|
||||
// A pie's slices and a heatmap's groups are not uPlot series.
|
||||
if (customLegend) {
|
||||
return customLegend(averageLegendWidth);
|
||||
return customLegend(layout);
|
||||
}
|
||||
return (
|
||||
<UPlotLegend
|
||||
config={config}
|
||||
position={legendConfig.position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
showSearch={showSearch}
|
||||
averageLegendWidth={layout.averageLegendWidth}
|
||||
showSearch={layout.showSearch}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import { HeatmapChartProps } from 'lib/visualization/charts/types';
|
||||
import type { LegendLayout } from 'lib/visualization/layout/ChartLayout/ChartLayout';
|
||||
import { useLegendVisibility } from 'lib/visualization/hooks/useLegendVisibility';
|
||||
import {
|
||||
buildHeatmapConfig,
|
||||
@@ -277,11 +278,13 @@ export default function Heatmap(props: HeatmapChartProps): JSX.Element {
|
||||
}, []);
|
||||
|
||||
const groupLegend = useCallback(
|
||||
(averageLegendWidth: number): React.ReactNode => (
|
||||
({ averageLegendWidth, showSearch }: LegendLayout): React.ReactNode => (
|
||||
<Legend
|
||||
items={legendItems}
|
||||
position={legendPosition}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
showSearch={showSearch}
|
||||
itemLabel="groups"
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onAction={onLegendAction}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import type { LegendLayout } from 'lib/visualization/layout/ChartLayout/ChartLayout';
|
||||
import type {
|
||||
HeatmapAxisScale,
|
||||
HeatmapCell,
|
||||
@@ -41,7 +42,7 @@ interface BaseChartProps {
|
||||
tooltipPortalRoot?: HTMLElement | null;
|
||||
/** Replaces the config-driven legend, for charts whose legend lists something
|
||||
* other than uPlot series — heatmap groups, where the series are bucket rows. */
|
||||
customLegend?: (averageLegendWidth: number) => React.ReactNode;
|
||||
customLegend?: (layout: LegendLayout) => React.ReactNode;
|
||||
/** Measured against for the chart/legend split. Pair with `customLegend`. */
|
||||
legendLabels?: string[];
|
||||
/** Rendered under the plot but above the legend, inside the chart column. */
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
background-color: var(--l1-background);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
padding-bottom: 44px;
|
||||
|
||||
@include custom-scrollbar;
|
||||
@@ -42,11 +43,12 @@
|
||||
|
||||
.eyebrow {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 16px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--l1-foreground);
|
||||
padding: 18px 20px 12px;
|
||||
font-family: 'Geist Mono', monospace;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.group {
|
||||
@@ -73,9 +75,5 @@
|
||||
.sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > * {
|
||||
padding: 0 16px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type {
|
||||
@@ -7,6 +8,7 @@ import type {
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { buildPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
@@ -22,9 +24,12 @@ import { PanelKind } from '../../Panels/types/panelKind';
|
||||
interface ConfigPaneProps {
|
||||
/** The panel spec — the single editing surface (title/description + section slices). */
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
/** Last saved spec, the baseline for each section's changed marker. */
|
||||
savedSpec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Switch the panel to another visualization kind. */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
originalPanelKind?: PanelKind;
|
||||
/**
|
||||
* Active query type from the query-builder provider (the selected tab). Drives which
|
||||
* panel types the visualization switcher disables — read from the provider, not the
|
||||
@@ -55,8 +60,10 @@ interface ConfigPaneProps {
|
||||
*/
|
||||
function ConfigPane({
|
||||
spec,
|
||||
savedSpec,
|
||||
onChangeSpec,
|
||||
onChangePanelKind,
|
||||
originalPanelKind,
|
||||
queryType,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
@@ -76,6 +83,11 @@ function ConfigPane({
|
||||
(config) => config.kind !== SectionKind.PanelHeader,
|
||||
);
|
||||
|
||||
const defaults = useMemo(
|
||||
() => buildPluginSpec(definition.sections),
|
||||
[definition],
|
||||
);
|
||||
|
||||
const signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[0]);
|
||||
|
||||
// Title/description are just a slice of the spec — edit them through the same
|
||||
@@ -119,12 +131,15 @@ function ConfigPane({
|
||||
bare
|
||||
config={headerSection}
|
||||
spec={spec}
|
||||
savedSpec={savedSpec}
|
||||
onChangeSpec={onChangeSpec}
|
||||
defaults={defaults}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
@@ -143,12 +158,15 @@ function ConfigPane({
|
||||
key={config.kind}
|
||||
config={config}
|
||||
spec={spec}
|
||||
savedSpec={savedSpec}
|
||||
onChangeSpec={onChangeSpec}
|
||||
defaults={defaults}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
originalPanelKind={originalPanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
|
||||
@@ -1,6 +1,54 @@
|
||||
// Matches ConfigPane's `.field` so the switcher lines up with the title/description fields.
|
||||
.field {
|
||||
@use '../../../../../../styles/scrollbar' as *;
|
||||
|
||||
.trigger {
|
||||
--button-height: 36px;
|
||||
--button-padding: 0 5px 0 12px;
|
||||
--button-gap: 8px;
|
||||
--button-justify-content: flex-start;
|
||||
--button-variant-outlined-border: 1px solid var(--l2-border);
|
||||
--button-variant-outlined-background-color: var(--l2-background);
|
||||
--button-variant-outlined-hover-background-color: var(--l2-background);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
--button-variant-outlined-border: 1px solid var(--l3-border);
|
||||
}
|
||||
}
|
||||
|
||||
.triggerIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.triggerName {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--l1-foreground);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.triggerAction {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 3px;
|
||||
background: var(--l3-background);
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.revert {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.drawerBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { ArrowRightLeft, Undo2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import PanelTypeBrowser from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeBrowser';
|
||||
import { getPanelDefinition } from '../../../Panels/registry';
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
|
||||
import ConfigField from '../controls/ConfigField/ConfigField';
|
||||
|
||||
import styles from './PanelTypeSwitcher.module.scss';
|
||||
import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
|
||||
interface PanelTypeSwitcherProps {
|
||||
/** The current panel kind (selected value). */
|
||||
@@ -15,33 +20,97 @@ interface PanelTypeSwitcherProps {
|
||||
queryType: EQueryType;
|
||||
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
/** Kind the panel was opened with; a revert button appears once it differs. */
|
||||
originalPanelKind?: PanelKind;
|
||||
onChange: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualization-type selector (rendered inside the Visualization section). A type is
|
||||
* disabled when the active query type or signal is incompatible with it — resolved
|
||||
* through the capabilities guard. The signal is unknown for PromQL/ClickHouse, but
|
||||
* those query types still disable kinds that only support Query Builder (e.g. List).
|
||||
* Visualization-type selector (rendered inside the Visualization section): opens the
|
||||
* panel type browser in a drawer. A type is disabled when the active query type or
|
||||
* signal is incompatible with it — resolved through the capabilities guard.
|
||||
*/
|
||||
function PanelTypeSwitcher({
|
||||
panelKind,
|
||||
queryType,
|
||||
signal,
|
||||
originalPanelKind,
|
||||
onChange,
|
||||
}: PanelTypeSwitcherProps): JSX.Element {
|
||||
const items = usePanelTypeSelectItems({ queryType, signal });
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { displayName, icon: Icon } = getPanelDefinition(panelKind);
|
||||
|
||||
const getDisabledReason = useCallback(
|
||||
(kind: PanelKind): string | undefined =>
|
||||
getPanelTypeDisabledReason({
|
||||
kind,
|
||||
queryType,
|
||||
signal,
|
||||
label: getPanelDefinition(kind).displayName,
|
||||
}),
|
||||
[queryType, signal],
|
||||
);
|
||||
|
||||
const canRevert = !!originalPanelKind && originalPanelKind !== panelKind;
|
||||
const revertBlockedReason = canRevert
|
||||
? getDisabledReason(originalPanelKind)
|
||||
: undefined;
|
||||
|
||||
const handleSelect = (kind: PanelKind): void => {
|
||||
setIsOpen(false);
|
||||
if (kind !== panelKind) {
|
||||
onChange(kind);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Panel Type</Typography.Text>
|
||||
<ConfigSelect
|
||||
<ConfigField label="Panel type">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className={styles.trigger}
|
||||
onClick={(): void => setIsOpen(true)}
|
||||
testId="panel-editor-v2-type-switcher"
|
||||
value={panelKind}
|
||||
items={items}
|
||||
onChange={(value): void => onChange(value)}
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
<Icon size={14} className={styles.triggerIcon} />
|
||||
<span className={styles.triggerName}>{displayName}</span>
|
||||
<span className={styles.triggerAction}>
|
||||
<ArrowRightLeft size={14} />
|
||||
Change
|
||||
</span>
|
||||
</Button>
|
||||
{canRevert && (
|
||||
<Button
|
||||
variant="link"
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<Undo2 />}
|
||||
className={styles.revert}
|
||||
disabled={!!revertBlockedReason}
|
||||
title={revertBlockedReason}
|
||||
onClick={(): void => onChange(originalPanelKind)}
|
||||
testId="panel-editor-v2-type-revert"
|
||||
>
|
||||
Revert to {getPanelDefinition(originalPanelKind).displayName}
|
||||
</Button>
|
||||
)}
|
||||
<DrawerWrapper
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
title="Change panel type"
|
||||
subTitle="Pick a visualization for this panel."
|
||||
direction="right"
|
||||
width="wide"
|
||||
testId="panel-type-switcher-drawer"
|
||||
drawerDescriptionProps={{ className: styles.drawerBody }}
|
||||
>
|
||||
<PanelTypeBrowser
|
||||
selectedKind={panelKind}
|
||||
onSelect={handleSelect}
|
||||
getDisabledReason={getDisabledReason}
|
||||
/>
|
||||
</DrawerWrapper>
|
||||
</ConfigField>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelTypeSwitcher from '../PanelTypeSwitcher';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
const OPTIONS = [
|
||||
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
|
||||
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
|
||||
{ kind: 'signoz/TablePanel', displayName: 'Table' },
|
||||
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
|
||||
{ kind: 'signoz/AreaChartPanel', displayName: 'Area' },
|
||||
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
|
||||
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
|
||||
{ kind: 'signoz/ListPanel', displayName: 'List' },
|
||||
{ kind: 'signoz/TextPanel', displayName: 'Text' },
|
||||
].map((option) => ({ ...option, icon: (): null => null }));
|
||||
|
||||
// Stub the registry so the test doesn't pull in the real renderers and chart libs.
|
||||
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
PANEL_OPTIONS: [
|
||||
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
|
||||
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
|
||||
{ kind: 'signoz/TablePanel', displayName: 'Table' },
|
||||
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
|
||||
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
|
||||
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
|
||||
{ kind: 'signoz/ListPanel', displayName: 'List' },
|
||||
].map((option) => ({ ...option, icon: (): null => null })),
|
||||
get PANEL_OPTIONS(): unknown {
|
||||
return OPTIONS;
|
||||
},
|
||||
}));
|
||||
|
||||
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
|
||||
@@ -28,14 +36,34 @@ const SUPPORTED_QUERY_TYPES: Record<string, EQueryType[]> = {
|
||||
'signoz/PieChartPanel': [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
};
|
||||
|
||||
function disabledLabels(): (string | null)[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).map((el) => el.textContent);
|
||||
type User = ReturnType<typeof userEvent.setup>;
|
||||
|
||||
async function renderSwitcher(
|
||||
props: Partial<Parameters<typeof PanelTypeSwitcher>[0]> = {},
|
||||
): Promise<{ onChange: jest.Mock; user: User }> {
|
||||
// The open drawer sets `pointer-events: none` on the body.
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
await user.click(screen.getByTestId('panel-editor-v2-type-switcher'));
|
||||
return { onChange, user };
|
||||
}
|
||||
|
||||
function openDropdown(): void {
|
||||
fireEvent.mouseDown(screen.getByRole('combobox'));
|
||||
function disabledKinds(): (string | undefined)[] {
|
||||
return Array.from(
|
||||
document.querySelectorAll('[data-testid^="panel-type-signoz/"]'),
|
||||
)
|
||||
.filter((el) => el.getAttribute('aria-disabled') === 'true')
|
||||
.map((el) => el.getAttribute('data-testid')?.replace('panel-type-', ''));
|
||||
}
|
||||
|
||||
describe('PanelTypeSwitcher', () => {
|
||||
@@ -44,7 +72,8 @@ describe('PanelTypeSwitcher', () => {
|
||||
// List supports only logs/traces; every other kind also supports metrics.
|
||||
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
|
||||
mockGetPanelDefinition.mockImplementation((kind: string) => ({
|
||||
mode: 'query',
|
||||
...OPTIONS.find((option) => option.kind === kind),
|
||||
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
|
||||
supportedSignals:
|
||||
kind === 'signoz/ListPanel'
|
||||
? ['logs', 'traces']
|
||||
@@ -57,83 +86,94 @@ describe('PanelTypeSwitcher', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('fires onChange with the chosen plugin kind', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
it('shows the current type and switches to the chosen one', async () => {
|
||||
const { onChange, user } = await renderSwitcher();
|
||||
|
||||
openDropdown();
|
||||
fireEvent.click(screen.getByText('List'));
|
||||
expect(screen.getByTestId('panel-editor-v2-type-switcher')).toHaveTextContent(
|
||||
'Time SeriesChange',
|
||||
);
|
||||
await user.click(screen.getByTestId('panel-type-signoz/ListPanel'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('signoz/ListPanel');
|
||||
});
|
||||
|
||||
it('disables types whose supported signals exclude the current signal', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
signal={TelemetrytypesSignalDTO.metrics}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
it('does not fire onChange when the current type is picked again', async () => {
|
||||
const { onChange, user } = await renderSwitcher();
|
||||
|
||||
openDropdown();
|
||||
// List can't render a metrics query, so it's disabled; Time Series stays enabled.
|
||||
expect(disabledLabels()).toContain('List');
|
||||
expect(disabledLabels()).not.toContain('Time Series');
|
||||
await user.click(screen.getByTestId('panel-type-signoz/TimeSeriesPanel'));
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not disable any type when the signal is unknown (builder, no signal)', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
it('disables types whose supported signals exclude the current signal', async () => {
|
||||
const { onChange, user } = await renderSwitcher({
|
||||
signal: TelemetrytypesSignalDTO.metrics,
|
||||
});
|
||||
|
||||
openDropdown();
|
||||
expect(
|
||||
document.querySelectorAll('.ant-select-item-option-disabled'),
|
||||
).toHaveLength(0);
|
||||
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
|
||||
await user.click(screen.getByTestId('panel-type-signoz/ListPanel'));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables Query-Builder-only kinds under PromQL even without a signal', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.PROM}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
it('does not disable any type when the signal is unknown (builder, no signal)', async () => {
|
||||
await renderSwitcher();
|
||||
|
||||
openDropdown();
|
||||
// List/Table/Pie can't be authored in PromQL; Time Series can.
|
||||
expect(disabledLabels()).toContain('List');
|
||||
expect(disabledLabels()).toContain('Table');
|
||||
expect(disabledLabels()).toContain('Pie Chart');
|
||||
expect(disabledLabels()).not.toContain('Time Series');
|
||||
expect(disabledKinds()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('disables List under ClickHouse while Table/Pie stay enabled', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TablePanel"
|
||||
queryType={EQueryType.CLICKHOUSE}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
it('disables Query-Builder-only kinds under PromQL even without a signal', async () => {
|
||||
await renderSwitcher({ queryType: EQueryType.PROM });
|
||||
|
||||
openDropdown();
|
||||
expect(disabledLabels()).toContain('List');
|
||||
expect(disabledLabels()).not.toContain('Table');
|
||||
expect(disabledLabels()).not.toContain('Pie Chart');
|
||||
expect(disabledLabels()).not.toContain('Time Series');
|
||||
expect(disabledKinds()).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
'signoz/ListPanel',
|
||||
'signoz/TablePanel',
|
||||
'signoz/PieChartPanel',
|
||||
]),
|
||||
);
|
||||
expect(disabledKinds()).not.toContain('signoz/TimeSeriesPanel');
|
||||
expect(disabledKinds()).not.toContain('signoz/TextPanel');
|
||||
});
|
||||
|
||||
it('disables List under ClickHouse while Table/Pie stay enabled', async () => {
|
||||
await renderSwitcher({
|
||||
panelKind: 'signoz/TablePanel',
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
});
|
||||
|
||||
expect(disabledKinds()).toStrictEqual(['signoz/ListPanel']);
|
||||
});
|
||||
|
||||
describe('revert', () => {
|
||||
it('is hidden while the type is the original one', async () => {
|
||||
await renderSwitcher({ originalPanelKind: 'signoz/TimeSeriesPanel' });
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-type-revert'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches back to the original type', async () => {
|
||||
const { onChange, user } = await renderSwitcher({
|
||||
panelKind: 'signoz/TablePanel',
|
||||
originalPanelKind: 'signoz/TimeSeriesPanel',
|
||||
});
|
||||
|
||||
const revert = screen.getByTestId('panel-editor-v2-type-revert');
|
||||
expect(revert).toHaveTextContent('Revert to Time Series');
|
||||
await user.click(revert);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('signoz/TimeSeriesPanel');
|
||||
});
|
||||
|
||||
it('is disabled when the original type no longer fits the query', async () => {
|
||||
await renderSwitcher({
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
originalPanelKind: 'signoz/ListPanel',
|
||||
queryType: EQueryType.PROM,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-type-revert')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,8 +18,7 @@ interface UsePanelTypeSelectItemsArgs {
|
||||
/**
|
||||
* Visualization-kind options for a `ConfigSelect`, each disabled (with a reason
|
||||
* tooltip) when the active query type or signal is incompatible — resolved through
|
||||
* the capabilities guard. Shared by the editor's `PanelTypeSwitcher` and the View
|
||||
* modal's header so the two selectors apply the same rule and can't drift.
|
||||
* the capabilities guard, the same rule the editor's `PanelTypeSwitcher` applies.
|
||||
*/
|
||||
export function usePanelTypeSelectItems({
|
||||
queryType,
|
||||
|
||||
@@ -6,16 +6,22 @@ import {
|
||||
type SectionConfig,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import type { SeededPluginSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/buildPluginSpec';
|
||||
|
||||
import type { SectionEditorContext } from '../sectionContext';
|
||||
import { resolveSectionEditor } from '../sectionRegistry';
|
||||
import { getSectionDefault, resolveSectionEditor } from '../sectionRegistry';
|
||||
import SettingsSection from '../SettingsSection/SettingsSection';
|
||||
import { isDifferent } from '../utils/changes';
|
||||
import SectionHeaderQuickAdd from './SectionHeaderQuickAdd';
|
||||
|
||||
type SectionSlotProps = {
|
||||
config: SectionConfig;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
/** Last saved spec; a section whose slice differs from it is marked changed. */
|
||||
savedSpec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** The kind's seeded plugin spec (`buildPluginSpec`). */
|
||||
defaults: SeededPluginSpec;
|
||||
/** Renders the editor alone, for a section promoted into the Panel Details fields. */
|
||||
bare?: boolean;
|
||||
} & Omit<SectionEditorContext, 'yAxisUnit' | 'registerHeaderAction'>;
|
||||
@@ -51,13 +57,16 @@ const SECTION_HEADER_SLOT: Partial<
|
||||
function SectionSlot({
|
||||
config,
|
||||
spec,
|
||||
savedSpec,
|
||||
onChangeSpec,
|
||||
defaults,
|
||||
bare,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
signal,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
originalPanelKind,
|
||||
queryType,
|
||||
stepInterval,
|
||||
metricUnit,
|
||||
@@ -103,8 +112,11 @@ function SectionSlot({
|
||||
return null;
|
||||
}
|
||||
|
||||
const { title, icon: Icon } = SECTION_METADATA[config.kind];
|
||||
const { title } = SECTION_METADATA[config.kind];
|
||||
const { Component, get, update } = editor;
|
||||
const value = get(spec);
|
||||
const defaultValue = getSectionDefault(editor, spec, defaults);
|
||||
const savedValue = get(savedSpec);
|
||||
// Atomic sections carry no `controls`; controlled ones do.
|
||||
const controls = 'controls' in config ? config.controls : undefined;
|
||||
// Forwarded to editors that scope to the panel's unit (e.g. the thresholds unit picker).
|
||||
@@ -113,20 +125,27 @@ function SectionSlot({
|
||||
|
||||
const headerSlot = SECTION_HEADER_SLOT[config.kind]?.(triggerHeaderAction);
|
||||
|
||||
const context: SectionEditorContext = {
|
||||
legendSeries,
|
||||
yAxisUnit,
|
||||
tableColumns,
|
||||
signal,
|
||||
panelKind,
|
||||
onChangePanelKind,
|
||||
originalPanelKind,
|
||||
queryType,
|
||||
stepInterval,
|
||||
metricUnit,
|
||||
};
|
||||
|
||||
const editorElement = (
|
||||
<Component
|
||||
value={get(spec)}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
savedValue={savedValue}
|
||||
controls={controls}
|
||||
onChange={(next): void => onChangeSpec(update(spec, next))}
|
||||
legendSeries={legendSeries}
|
||||
yAxisUnit={yAxisUnit}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
{...context}
|
||||
registerHeaderAction={registerHeaderAction}
|
||||
/>
|
||||
);
|
||||
@@ -138,7 +157,7 @@ function SectionSlot({
|
||||
return (
|
||||
<SettingsSection
|
||||
title={title}
|
||||
icon={<Icon size={15} />}
|
||||
changed={isDifferent(value, savedValue)}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
headerSlot={headerSlot}
|
||||
|
||||
@@ -24,11 +24,16 @@ function makeSpec(thresholds: unknown[] = []): DashboardtypesPanelSpecDTO {
|
||||
|
||||
// Stateful harness so onChange feeds back into the spec (as ConfigPane owns it).
|
||||
function Harness({ initial = [] }: { initial?: unknown[] } = {}): JSX.Element {
|
||||
const [spec, setSpec] = useState<DashboardtypesPanelSpecDTO>(
|
||||
makeSpec(initial),
|
||||
);
|
||||
const [savedSpec] = useState(() => makeSpec(initial));
|
||||
const [spec, setSpec] = useState<DashboardtypesPanelSpecDTO>(savedSpec);
|
||||
return (
|
||||
<SectionSlot config={THRESHOLDS_CONFIG} spec={spec} onChangeSpec={setSpec} />
|
||||
<SectionSlot
|
||||
config={THRESHOLDS_CONFIG}
|
||||
spec={spec}
|
||||
savedSpec={savedSpec}
|
||||
defaults={{}}
|
||||
onChangeSpec={setSpec}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,3 +75,49 @@ describe('SectionSlot header action', () => {
|
||||
expect(screen.getByTestId('threshold-value-0')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SectionSlot changed marker', () => {
|
||||
it('shows no marker when the section matches the saved spec', () => {
|
||||
render(
|
||||
<Harness initial={[{ value: 80, color: '#F5B225', label: 'High' }]} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('config-changed-dot')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks a section edited since the last save', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-add-threshold-header'));
|
||||
|
||||
expect(screen.getByTestId('config-changed-dot')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('treats an unset saved slice and an explicit empty one as unchanged', () => {
|
||||
const savedSpec = {
|
||||
display: { name: 'CPU' },
|
||||
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
const spec = {
|
||||
...savedSpec,
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: { formatting: { unit: '' } },
|
||||
},
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
render(
|
||||
<SectionSlot
|
||||
config={{ kind: SectionKind.Formatting, controls: { decimals: true } }}
|
||||
spec={spec}
|
||||
savedSpec={savedSpec}
|
||||
defaults={{}}
|
||||
onChangeSpec={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('config-changed-dot')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,51 +1,42 @@
|
||||
.section {
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 20px 0 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
}
|
||||
|
||||
// Disclosure control (icon tile + title); fills the row so the action slot and chevron sit right.
|
||||
.toggle {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 0 !important;
|
||||
padding: 18px 0 18px 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
color: var(--text-vanilla-100);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.iconTile {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
flex: none;
|
||||
border-radius: 3px;
|
||||
background: var(--l3-background);
|
||||
color: var(--l3-foreground);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.iconTileOpen {
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 14%, transparent);
|
||||
color: var(--bg-robin-400);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--l3-foreground);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&.open {
|
||||
@@ -56,6 +47,6 @@
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 2px 0 18px;
|
||||
gap: 26px;
|
||||
padding: 4px 20px 26px;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { ChevronDown } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import ChangedDot from '../controls/ChangedDot/ChangedDot';
|
||||
|
||||
import styles from './SettingsSection.module.scss';
|
||||
|
||||
interface SettingsSectionProps {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
/** Controlled open state; when set, the section defers to `onOpenChange`. */
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
changed?: boolean;
|
||||
/** Rendered between the title and the chevron. */
|
||||
headerSlot?: ReactNode;
|
||||
children: ReactNode;
|
||||
@@ -23,10 +23,10 @@ interface SettingsSectionProps {
|
||||
*/
|
||||
function SettingsSection({
|
||||
title,
|
||||
icon,
|
||||
defaultOpen = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
changed,
|
||||
headerSlot,
|
||||
children,
|
||||
}: SettingsSectionProps): JSX.Element {
|
||||
@@ -51,30 +51,17 @@ function SettingsSection({
|
||||
type="button"
|
||||
className={styles.toggle}
|
||||
aria-expanded={isOpen}
|
||||
aria-label={isOpen ? `Collapse ${title}` : `Expand ${title}`}
|
||||
data-testid={`config-section-${serializedTitle}`}
|
||||
onClick={toggle}
|
||||
>
|
||||
{icon && (
|
||||
<span className={cx(styles.iconTile, { [styles.iconTileOpen]: isOpen })}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
<span className={styles.title}>{title}</span>
|
||||
{changed && <ChangedDot title="Unsaved changes" />}
|
||||
</button>
|
||||
{headerSlot}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
prefix={
|
||||
<ChevronDown
|
||||
size={15}
|
||||
className={cx(styles.chevron, { [styles.open]: isOpen })}
|
||||
/>
|
||||
}
|
||||
aria-label={isOpen ? `Collapse ${title}` : `Expand ${title}`}
|
||||
tabIndex={-1}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cx(styles.chevron, { [styles.open]: isOpen })}
|
||||
onClick={toggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,7 @@ function renderConfigPane(
|
||||
): React.ComponentProps<typeof ConfigPane> {
|
||||
const props: React.ComponentProps<typeof ConfigPane> = {
|
||||
spec: spec(),
|
||||
savedSpec: spec(),
|
||||
onChangeSpec: jest.fn(),
|
||||
onChangePanelKind: jest.fn(),
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
@@ -108,7 +109,7 @@ describe('ConfigPane', () => {
|
||||
|
||||
const toggle = screen.getByTestId('panel-header-hide');
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(screen.getByText('Hide header')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hide panel header')).toBeInTheDocument();
|
||||
// No collapsible wrapper of its own.
|
||||
expect(screen.queryByText('Panel header')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
flex: none;
|
||||
grid-template-columns: repeat(3, 28px);
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.cell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background);
|
||||
}
|
||||
|
||||
&:has(:focus-visible) {
|
||||
outline: 1px solid var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 10px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.selected {
|
||||
border-color: var(--bg-robin-500);
|
||||
background: color-mix(in srgb, var(--bg-robin-500) 12%, transparent);
|
||||
|
||||
.mark {
|
||||
width: 14px;
|
||||
background: var(--bg-robin-400);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useId } from 'react';
|
||||
import cx from 'classnames';
|
||||
|
||||
import { type Alignment, ALIGNMENT_CELLS, alignmentLabel } from './alignment';
|
||||
|
||||
import styles from './AlignmentGrid.module.scss';
|
||||
|
||||
interface AlignmentGridProps {
|
||||
testId: string;
|
||||
value: Alignment;
|
||||
description?: string;
|
||||
onChange: (next: Alignment) => void;
|
||||
}
|
||||
|
||||
function AlignmentGrid({
|
||||
testId,
|
||||
value,
|
||||
description,
|
||||
onChange,
|
||||
}: AlignmentGridProps): JSX.Element {
|
||||
const name = useId();
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Text position"
|
||||
data-testid={testId}
|
||||
className={styles.grid}
|
||||
>
|
||||
{ALIGNMENT_CELLS.map((cell) => {
|
||||
const label = alignmentLabel(cell);
|
||||
const selected =
|
||||
cell.textAlign === value.textAlign &&
|
||||
cell.verticalAlign === value.verticalAlign;
|
||||
return (
|
||||
<label
|
||||
key={label}
|
||||
title={label}
|
||||
data-testid={`${testId}-${cell.verticalAlign}-${cell.textAlign}`}
|
||||
className={cx(styles.cell, { [styles.selected]: selected })}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className={styles.input}
|
||||
name={name}
|
||||
checked={selected}
|
||||
aria-label={label}
|
||||
onChange={(): void => onChange(cell)}
|
||||
/>
|
||||
<span className={styles.mark} />
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.text}>
|
||||
<span className={styles.label}>{alignmentLabel(value)}</span>
|
||||
{description && <span className={styles.description}>{description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AlignmentGrid;
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
DashboardtypesTextAlignDTO,
|
||||
DashboardtypesVerticalAlignDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
const VERTICAL_LABELS: Record<DashboardtypesVerticalAlignDTO, string> = {
|
||||
[DashboardtypesVerticalAlignDTO.top]: 'Top',
|
||||
[DashboardtypesVerticalAlignDTO.center]: 'Middle',
|
||||
[DashboardtypesVerticalAlignDTO.bottom]: 'Bottom',
|
||||
};
|
||||
|
||||
const HORIZONTAL_LABELS: Record<DashboardtypesTextAlignDTO, string> = {
|
||||
[DashboardtypesTextAlignDTO.left]: 'left',
|
||||
[DashboardtypesTextAlignDTO.center]: 'center',
|
||||
[DashboardtypesTextAlignDTO.right]: 'right',
|
||||
};
|
||||
|
||||
export interface Alignment {
|
||||
textAlign: DashboardtypesTextAlignDTO;
|
||||
verticalAlign: DashboardtypesVerticalAlignDTO;
|
||||
}
|
||||
|
||||
export const ALIGNMENT_CELLS: Alignment[] = Object.values(
|
||||
DashboardtypesVerticalAlignDTO,
|
||||
).flatMap((verticalAlign) =>
|
||||
Object.values(DashboardtypesTextAlignDTO).map((textAlign) => ({
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
})),
|
||||
);
|
||||
|
||||
export function alignmentLabel({
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
}: Alignment): string {
|
||||
return `${VERTICAL_LABELS[verticalAlign]} ${HORIZONTAL_LABELS[textAlign]}`;
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Check } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
TEXT_BACKGROUND_PAIRS,
|
||||
TEXT_BACKGROUND_PRESETS,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
|
||||
import type {
|
||||
PanelTheme,
|
||||
TextBackgroundPreset,
|
||||
TextBackgroundSelection,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
|
||||
import { TextBackgroundKind } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
|
||||
|
||||
import styles from './BackgroundSwatches.module.scss';
|
||||
|
||||
const PRESET_TITLES: Record<TextBackgroundPreset, string> = {
|
||||
robin: 'Robin',
|
||||
purple: 'Purple',
|
||||
sakura: 'Sakura',
|
||||
cherry: 'Cherry',
|
||||
amber: 'Amber',
|
||||
forest: 'Forest',
|
||||
sienna: 'Sienna',
|
||||
slate: 'Slate',
|
||||
};
|
||||
|
||||
type BaseSelection = TextBackgroundKind.None | TextBackgroundKind.Default;
|
||||
|
||||
const BASE_TITLES: Record<BaseSelection, string> = {
|
||||
none: 'Transparent',
|
||||
default: 'Default panel',
|
||||
};
|
||||
|
||||
/** Neither base swatch shows a colour, so its tooltip says what it does. */
|
||||
const BASE_TOOLTIPS: Record<BaseSelection, string> = {
|
||||
none: 'Transparent — no card, border or title bar',
|
||||
default: 'Default panel colour',
|
||||
};
|
||||
|
||||
const OPTIONS: TextBackgroundSelection[] = [
|
||||
TextBackgroundKind.None,
|
||||
TextBackgroundKind.Default,
|
||||
...TEXT_BACKGROUND_PRESETS,
|
||||
];
|
||||
|
||||
const DIVIDER_AFTER = 1;
|
||||
|
||||
interface BackgroundSwatchesProps {
|
||||
testId: string;
|
||||
/** Names the group for assistive tech — the row carries no visible label. */
|
||||
label: string;
|
||||
/** `undefined` while a custom colour is active: no swatch is selected. */
|
||||
value: TextBackgroundSelection | undefined;
|
||||
/** Swatches paint in this theme's pair, so what the user picks is what they see. */
|
||||
theme: PanelTheme;
|
||||
onChange: (value: TextBackgroundSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Text panel's background choices as one radio group. Native radios sharing a
|
||||
* `name`, so arrow-key movement, the single tab stop and selection-follows-focus
|
||||
* are the platform's; each input is transparent and fills its swatch.
|
||||
*/
|
||||
function BackgroundSwatches({
|
||||
testId,
|
||||
label,
|
||||
value,
|
||||
theme,
|
||||
onChange,
|
||||
}: BackgroundSwatchesProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.row}
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
>
|
||||
{OPTIONS.map((option, index) => {
|
||||
const isBase =
|
||||
option === TextBackgroundKind.None ||
|
||||
option === TextBackgroundKind.Default;
|
||||
const pair = isBase ? undefined : TEXT_BACKGROUND_PAIRS[option][theme];
|
||||
const title = isBase ? BASE_TITLES[option] : PRESET_TITLES[option];
|
||||
|
||||
return (
|
||||
<Fragment key={option}>
|
||||
<TooltipSimple title={isBase ? BASE_TOOLTIPS[option] : title} arrow>
|
||||
<label
|
||||
className={cx(styles.swatch, {
|
||||
[styles.checkerboard]: option === TextBackgroundKind.None,
|
||||
[styles.defaultSurface]: option === TextBackgroundKind.Default,
|
||||
[styles.selected]: option === value,
|
||||
})}
|
||||
style={pair ? { background: pair.surface, color: pair.ink } : undefined}
|
||||
data-testid={`${testId}-${option}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className={styles.input}
|
||||
name={testId}
|
||||
value={option}
|
||||
checked={option === value}
|
||||
aria-label={title}
|
||||
onChange={(): void => onChange(option)}
|
||||
/>
|
||||
{option === value && <Check size={14} />}
|
||||
</label>
|
||||
</TooltipSimple>
|
||||
{index === DIVIDER_AFTER && <span className={styles.divider} />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BackgroundSwatches;
|
||||
@@ -1,67 +0,0 @@
|
||||
.row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.active {
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
.chip {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
// No colour to show yet, so the chip advertises that it opens a picker.
|
||||
.chipEmpty {
|
||||
background: conic-gradient(
|
||||
from 0deg,
|
||||
var(--bg-cherry-400),
|
||||
var(--bg-amber-400),
|
||||
var(--bg-forest-400),
|
||||
var(--bg-robin-400),
|
||||
var(--bg-sakura-400),
|
||||
var(--bg-cherry-400)
|
||||
);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.hex {
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-vanilla-400);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
// Appended under the picker's own panel.
|
||||
.contrast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 4px 2px;
|
||||
font-size: 12px;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--bg-amber-400);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Check, ChevronDown, TriangleAlert } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ColorPicker } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
contrastRatio,
|
||||
inkForSurface,
|
||||
MIN_CONTRAST_RATIO,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/contrast';
|
||||
|
||||
import styles from './CustomBackgroundRow.module.scss';
|
||||
|
||||
const HEX_PLACEHOLDER = '#______';
|
||||
|
||||
/** What the picker opens on before a colour is chosen. */
|
||||
const INITIAL_COLOR = '#3A2A63';
|
||||
|
||||
interface CustomBackgroundRowProps {
|
||||
testId: string;
|
||||
/** The stored hex while a custom colour is active; `undefined` otherwise. */
|
||||
value: string | undefined;
|
||||
onChange: (hex: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The custom colour, as a row rather than a swatch: it opens a picker instead of
|
||||
* committing a value in one click. The picker warns below the contrast floor but
|
||||
* never blocks the choice.
|
||||
*/
|
||||
function CustomBackgroundRow({
|
||||
testId,
|
||||
value,
|
||||
onChange,
|
||||
}: CustomBackgroundRowProps): JSX.Element {
|
||||
const color = value ?? INITIAL_COLOR;
|
||||
const ratio = contrastRatio(inkForSurface(color), color);
|
||||
const isLegible = ratio >= MIN_CONTRAST_RATIO;
|
||||
|
||||
const contrastMessage = isLegible
|
||||
? `Contrast ${ratio.toFixed(1)}:1`
|
||||
: `Contrast ${ratio.toFixed(1)}:1 — below ${MIN_CONTRAST_RATIO}:1`;
|
||||
|
||||
function renderPanel(panel: ReactNode): ReactNode {
|
||||
return (
|
||||
<>
|
||||
{panel}
|
||||
<div
|
||||
className={cx(styles.contrast, { [styles.warning]: !isLegible })}
|
||||
data-testid={`${testId}-contrast`}
|
||||
>
|
||||
{!isLegible && <TriangleAlert size={12} />}
|
||||
<span className="translate-safe">{contrastMessage}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ColorPicker
|
||||
value={color}
|
||||
size="small"
|
||||
showText={false}
|
||||
trigger="click"
|
||||
panelRender={renderPanel}
|
||||
onChangeComplete={(next): void => onChange(next.toHexString())}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cx(styles.row, { [styles.active]: value !== undefined })}
|
||||
data-testid={testId}
|
||||
>
|
||||
<span
|
||||
className={cx(styles.chip, { [styles.chipEmpty]: value === undefined })}
|
||||
style={
|
||||
value ? { background: value, color: inkForSurface(value) } : undefined
|
||||
}
|
||||
>
|
||||
{value !== undefined && <Check size={14} />}
|
||||
</span>
|
||||
<Typography.Text className={styles.label}>Custom</Typography.Text>
|
||||
<span className={cx(styles.hex, 'translate-safe')}>
|
||||
{value ?? HEX_PLACEHOLDER}
|
||||
</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</ColorPicker>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomBackgroundRow;
|
||||
@@ -1,118 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { TEXT_BACKGROUND_PAIRS } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
|
||||
import {
|
||||
PanelTheme,
|
||||
TextBackgroundKind,
|
||||
TextBackgroundPreset,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
|
||||
|
||||
import BackgroundSwatches from '../BackgroundSwatches';
|
||||
|
||||
function renderRow(
|
||||
props: Partial<React.ComponentProps<typeof BackgroundSwatches>> = {},
|
||||
): jest.Mock {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<BackgroundSwatches
|
||||
testId="background"
|
||||
label="Panel background"
|
||||
theme={PanelTheme.Dark}
|
||||
value={TextBackgroundKind.Default}
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
return onChange;
|
||||
}
|
||||
|
||||
describe('BackgroundSwatches', () => {
|
||||
it('offers transparent, the default surface and the eight presets in order', () => {
|
||||
renderRow();
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole('radio')
|
||||
.map((swatch) => swatch.getAttribute('aria-label')),
|
||||
).toStrictEqual([
|
||||
'Transparent',
|
||||
'Default panel',
|
||||
'Robin',
|
||||
'Purple',
|
||||
'Sakura',
|
||||
'Cherry',
|
||||
'Amber',
|
||||
'Forest',
|
||||
'Sienna',
|
||||
'Slate',
|
||||
]);
|
||||
});
|
||||
|
||||
it('is one labelled group', () => {
|
||||
renderRow();
|
||||
|
||||
expect(
|
||||
screen.getByRole('radiogroup', { name: 'Panel background' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['background-none', 'Transparent — no card, border or title bar'],
|
||||
['background-default', 'Default panel colour'],
|
||||
['background-sakura', 'Sakura'],
|
||||
])('explains %s on hover', async (swatchId, copy) => {
|
||||
renderRow();
|
||||
|
||||
fireEvent.focus(screen.getByTestId(swatchId));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(copy);
|
||||
});
|
||||
});
|
||||
|
||||
it('paints each preset in the given theme', () => {
|
||||
renderRow({ theme: PanelTheme.Light });
|
||||
|
||||
expect(screen.getByTestId('background-amber')).toHaveStyle({
|
||||
background: TEXT_BACKGROUND_PAIRS.amber.light.surface,
|
||||
color: TEXT_BACKGROUND_PAIRS.amber.light.ink,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks only the selected swatch, and checks it', () => {
|
||||
renderRow({ value: TextBackgroundPreset.Forest });
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Forest' })).toBeChecked();
|
||||
expect(
|
||||
screen.getByRole('radio', { name: 'Default panel' }),
|
||||
).not.toBeChecked();
|
||||
expect(
|
||||
screen.getByTestId('background-forest').querySelector('svg'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('background-default').querySelector('svg'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reports the swatch that was clicked', () => {
|
||||
const onChange = renderRow();
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Sienna' }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('sienna');
|
||||
});
|
||||
|
||||
// jsdom does not implement radio arrow navigation, so the shared name — what
|
||||
// makes them one group — is what there is to assert.
|
||||
it('groups every swatch under one radio name', () => {
|
||||
renderRow();
|
||||
|
||||
const names = new Set(
|
||||
screen.getAllByRole('radio').map((swatch) => swatch.getAttribute('name')),
|
||||
);
|
||||
|
||||
expect(names).toStrictEqual(new Set(['background']));
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import CustomBackgroundRow from '../CustomBackgroundRow';
|
||||
|
||||
function renderRow(value?: string): jest.Mock {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<CustomBackgroundRow testId="custom" value={value} onChange={onChange} />,
|
||||
);
|
||||
return onChange;
|
||||
}
|
||||
|
||||
describe('CustomBackgroundRow', () => {
|
||||
it('stands in for the hex while no custom colour is set', () => {
|
||||
renderRow();
|
||||
|
||||
expect(screen.getByTestId('custom')).toHaveTextContent('#______');
|
||||
});
|
||||
|
||||
it('shows the stored hex once one is set', () => {
|
||||
renderRow('#3A2A63');
|
||||
|
||||
expect(screen.getByTestId('custom')).toHaveTextContent('#3A2A63');
|
||||
});
|
||||
|
||||
it('checks the chip only while the custom colour is the selection', () => {
|
||||
renderRow('#3A2A63');
|
||||
|
||||
expect(screen.getByTestId('custom').querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves the chip unchecked while no custom colour is set', () => {
|
||||
renderRow();
|
||||
|
||||
expect(screen.getByTestId('custom').querySelectorAll('svg')).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('the picker', () => {
|
||||
it('opens on the row', () => {
|
||||
renderRow('#3A2A63');
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom'));
|
||||
|
||||
expect(screen.getByTestId('custom-contrast')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reports the contrast the colour achieves', () => {
|
||||
renderRow('#3A2A63');
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom'));
|
||||
|
||||
// The derived ink is pure white, not purple's paired ink.
|
||||
expect(screen.getByTestId('custom-contrast')).toHaveTextContent(
|
||||
'Contrast 12.4:1',
|
||||
);
|
||||
});
|
||||
|
||||
it('warns when no ink clears the floor, without disabling anything', () => {
|
||||
renderRow('#808080');
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom'));
|
||||
|
||||
expect(screen.getByTestId('custom-contrast')).toHaveTextContent(
|
||||
'below 4.5:1',
|
||||
);
|
||||
expect(screen.getByTestId('custom')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('says nothing about the floor when the colour clears it', () => {
|
||||
renderRow('#111111');
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom'));
|
||||
|
||||
expect(screen.getByTestId('custom-contrast')).not.toHaveTextContent('below');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
.dot {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-robin-500);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import styles from './ChangedDot.module.scss';
|
||||
|
||||
function ChangedDot({
|
||||
title = 'Unsaved changes',
|
||||
}: {
|
||||
title?: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={styles.dot}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
data-testid="config-changed-dot"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChangedDot;
|
||||
@@ -1,32 +1,34 @@
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
// The input carries focus, so the ring is drawn on the swatch around it.
|
||||
&:has(.input:focus-visible) {
|
||||
&:has(.input:focus-visible),
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--bg-robin-400);
|
||||
outline-offset: 1px;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// The real control, sized to the swatch and invisible over it: clicks and focus
|
||||
// land on the radio, the swatch is what the user sees.
|
||||
// Clicks and focus land on the radio; the swatch is what the user sees.
|
||||
.input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -35,12 +37,13 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selected {
|
||||
box-shadow: 0 0 0 2px var(--bg-robin-500);
|
||||
.selected,
|
||||
.selected:has(.input:focus-visible) {
|
||||
outline: 2px solid var(--l1-foreground);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
// Transparency has no colour to show, so it reads as the conventional checkerboard.
|
||||
.checkerboard {
|
||||
.transparent {
|
||||
background-color: var(--l2-background);
|
||||
background-image:
|
||||
linear-gradient(
|
||||
@@ -63,14 +66,29 @@
|
||||
4px 4px;
|
||||
}
|
||||
|
||||
.defaultSurface {
|
||||
.surface {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.custom {
|
||||
background: conic-gradient(
|
||||
from 0deg,
|
||||
var(--bg-cherry-400),
|
||||
var(--bg-amber-400),
|
||||
var(--bg-forest-400),
|
||||
var(--bg-robin-400),
|
||||
var(--bg-sakura-400),
|
||||
var(--bg-cherry-400)
|
||||
);
|
||||
}
|
||||
|
||||
.customAnchor {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex: none;
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
margin: 0 2px;
|
||||
background: var(--l2-border);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
|
||||
import CustomColorSwatch from './CustomColorSwatch';
|
||||
|
||||
import styles from './ColorSwatches.module.scss';
|
||||
|
||||
export interface ColorSwatchOption<T extends string> {
|
||||
value: T;
|
||||
id: string;
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
fill?: string;
|
||||
pattern?: 'transparent' | 'surface';
|
||||
}
|
||||
|
||||
interface CustomColor {
|
||||
/** The active custom color; `undefined` while a preset is selected. */
|
||||
value: string | undefined;
|
||||
initial: string;
|
||||
onChange: (hex: string) => void;
|
||||
pickerFooter?: (hex: string) => ReactNode;
|
||||
}
|
||||
|
||||
interface ColorSwatchesProps<T extends string> {
|
||||
testId: string;
|
||||
label: string;
|
||||
value: T | undefined;
|
||||
options: ColorSwatchOption<T>[];
|
||||
dividerAfter?: number;
|
||||
onChange: (value: T) => void;
|
||||
custom: CustomColor;
|
||||
}
|
||||
|
||||
function ColorSwatches<T extends string>({
|
||||
testId,
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
dividerAfter,
|
||||
onChange,
|
||||
custom,
|
||||
}: ColorSwatchesProps<T>): JSX.Element {
|
||||
return (
|
||||
<div className={styles.row} data-testid={testId}>
|
||||
<div role="radiogroup" aria-label={label} className={styles.group}>
|
||||
{options.map((option, index) => (
|
||||
<Fragment key={option.id}>
|
||||
<TooltipSimple title={option.tooltip ?? option.label} arrow>
|
||||
<label
|
||||
className={cx(styles.swatch, {
|
||||
[styles.transparent]: option.pattern === 'transparent',
|
||||
[styles.surface]: option.pattern === 'surface',
|
||||
[styles.selected]: option.value === value,
|
||||
})}
|
||||
style={option.fill ? { background: option.fill } : undefined}
|
||||
data-testid={`${testId}-${option.id}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className={styles.input}
|
||||
name={testId}
|
||||
value={option.value}
|
||||
checked={option.value === value}
|
||||
aria-label={option.label}
|
||||
onChange={(): void => onChange(option.value)}
|
||||
/>
|
||||
</label>
|
||||
</TooltipSimple>
|
||||
{index === dividerAfter && <span className={styles.divider} />}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<CustomColorSwatch testId={`${testId}-custom`} {...custom} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ColorSwatches;
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { ColorPicker } from 'antd';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './ColorSwatches.module.scss';
|
||||
|
||||
interface CustomColorSwatchProps {
|
||||
testId: string;
|
||||
value: string | undefined;
|
||||
initial: string;
|
||||
onChange: (hex: string) => void;
|
||||
pickerFooter?: (hex: string) => ReactNode;
|
||||
}
|
||||
|
||||
function CustomColorSwatch({
|
||||
testId,
|
||||
value,
|
||||
initial,
|
||||
onChange,
|
||||
pickerFooter,
|
||||
}: CustomColorSwatchProps): JSX.Element {
|
||||
const color = value ?? initial;
|
||||
const title = value ? `Custom ${value.toUpperCase()}` : 'Custom color';
|
||||
|
||||
return (
|
||||
<ColorPicker
|
||||
value={color}
|
||||
trigger="click"
|
||||
panelRender={
|
||||
pickerFooter
|
||||
? (panel): ReactNode => (
|
||||
<>
|
||||
{panel}
|
||||
{pickerFooter(color)}
|
||||
</>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
onChangeComplete={(next): void => onChange(next.toHexString())}
|
||||
>
|
||||
<span className={styles.customAnchor}>
|
||||
<TooltipSimple title={title} arrow>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={title}
|
||||
aria-pressed={value !== undefined}
|
||||
className={cx(styles.swatch, styles.custom, {
|
||||
[styles.selected]: value !== undefined,
|
||||
})}
|
||||
data-testid={testId}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
</span>
|
||||
</ColorPicker>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomColorSwatch;
|
||||
@@ -0,0 +1,135 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
|
||||
import ColorSwatches, { type ColorSwatchOption } from '../ColorSwatches';
|
||||
|
||||
const OPTIONS: ColorSwatchOption<string>[] = [
|
||||
{ value: 'none', id: 'none', label: 'Transparent', pattern: 'transparent' },
|
||||
{
|
||||
value: 'default',
|
||||
id: 'default',
|
||||
label: 'Default panel',
|
||||
tooltip: 'Default panel color',
|
||||
pattern: 'surface',
|
||||
},
|
||||
{ value: '#ff0000', id: 'red', label: 'Red', fill: '#ff0000' },
|
||||
{ value: '#00ff00', id: 'green', label: 'Green', fill: '#00ff00' },
|
||||
];
|
||||
|
||||
function renderSwatches(
|
||||
props: Partial<React.ComponentProps<typeof ColorSwatches<string>>> = {},
|
||||
): { onChange: jest.Mock; onCustom: jest.Mock } {
|
||||
const onChange = jest.fn();
|
||||
const onCustom = jest.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ColorSwatches
|
||||
testId="color"
|
||||
label="Color"
|
||||
value="default"
|
||||
options={OPTIONS}
|
||||
dividerAfter={1}
|
||||
onChange={onChange}
|
||||
custom={{
|
||||
value: undefined,
|
||||
initial: '#3A2A63',
|
||||
onChange: onCustom,
|
||||
pickerFooter: (hex) => <span data-testid="footer">{hex}</span>,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
return { onChange, onCustom };
|
||||
}
|
||||
|
||||
describe('ColorSwatches', () => {
|
||||
it('is one labelled radio group, in option order', () => {
|
||||
renderSwatches();
|
||||
|
||||
expect(screen.getByRole('radiogroup', { name: 'Color' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByRole('radio').map((r) => r.getAttribute('aria-label')),
|
||||
).toStrictEqual(['Transparent', 'Default panel', 'Red', 'Green']);
|
||||
});
|
||||
|
||||
it('paints each swatch its fill', () => {
|
||||
renderSwatches();
|
||||
|
||||
expect(screen.getByTestId('color-red')).toHaveStyle({
|
||||
background: '#ff0000',
|
||||
});
|
||||
});
|
||||
|
||||
it('checks only the selected swatch', () => {
|
||||
renderSwatches({ value: '#00ff00' });
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Green' })).toBeChecked();
|
||||
expect(screen.getByRole('radio', { name: 'Red' })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('reports the swatch that was clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onChange } = renderSwatches();
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: 'Red' }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('#ff0000');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['color-default', 'Default panel color'],
|
||||
['color-red', 'Red'],
|
||||
])('explains %s on hover', async (swatchId, copy) => {
|
||||
const user = userEvent.setup();
|
||||
renderSwatches();
|
||||
|
||||
await user.hover(screen.getByTestId(swatchId));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(copy);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the custom swatch', () => {
|
||||
it('is pressed only while a custom color is active', () => {
|
||||
renderSwatches();
|
||||
expect(screen.getByTestId('color-custom')).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('names the active custom color', () => {
|
||||
renderSwatches({
|
||||
value: undefined,
|
||||
custom: { value: '#3a2a64', initial: '#000000', onChange: jest.fn() },
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Custom #3A2A64' }),
|
||||
).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
it('opens a picker with the footer, starting from the initial color', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSwatches();
|
||||
|
||||
await user.click(screen.getByTestId('color-custom'));
|
||||
|
||||
expect(screen.getByTestId('footer')).toHaveTextContent('#3A2A63');
|
||||
});
|
||||
|
||||
it('reports the color picked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onCustom } = renderSwatches();
|
||||
|
||||
await user.click(screen.getByTestId('color-custom'));
|
||||
await user.clear(screen.getByRole('textbox'));
|
||||
await user.type(screen.getByRole('textbox'), '3A2A64');
|
||||
|
||||
expect(onCustom).toHaveBeenCalledWith('#3a2a64');
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user