mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-08 23:50:40 +01:00
Compare commits
42 Commits
issue-5535
...
fixes/dash
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff1cc79c00 | ||
|
|
83dc3ef430 | ||
|
|
03114ed645 | ||
|
|
890234ac6c | ||
|
|
ec04a885f4 | ||
|
|
a71aacc8fd | ||
|
|
05f3862e23 | ||
|
|
d26a904d94 | ||
|
|
85895d6c4f | ||
|
|
2efbd04ae1 | ||
|
|
bb9acc2f65 | ||
|
|
67608b1d8c | ||
|
|
f26c8adb3b | ||
|
|
f5a395f229 | ||
|
|
b1dcbe87fe | ||
|
|
a36081b00c | ||
|
|
48d2460c8e | ||
|
|
14d9bbfce9 | ||
|
|
9c48d17fed | ||
|
|
33c6cbecda | ||
|
|
9429c9e632 | ||
|
|
7821961a6f | ||
|
|
08de39cdbd | ||
|
|
98178795ec | ||
|
|
0797b26cb2 | ||
|
|
f8846026fb | ||
|
|
5ec6560613 | ||
|
|
46e092b2c9 | ||
|
|
fe660e27a2 | ||
|
|
465620fbbb | ||
|
|
db2e29dde9 | ||
|
|
cec72e25fd | ||
|
|
82c833044d | ||
|
|
f98b8bb1fd | ||
|
|
2114fbddfe | ||
|
|
b2214cf5a3 | ||
|
|
db4219e0f1 | ||
|
|
165c945511 | ||
|
|
ff19aedaa8 | ||
|
|
ef8319585b | ||
|
|
14ba332935 | ||
|
|
fda7b66d49 |
8
.github/workflows/integrationci.yaml
vendored
8
.github/workflows/integrationci.yaml
vendored
@@ -48,7 +48,11 @@ jobs:
|
||||
- logspipelines
|
||||
- passwordauthn
|
||||
- preference
|
||||
- querier
|
||||
- querierlogs
|
||||
- queriertraces
|
||||
- queriermetrics
|
||||
- querierscalar
|
||||
- queriercommon
|
||||
- rawexportdata
|
||||
- role
|
||||
- rootuser
|
||||
@@ -56,8 +60,6 @@ jobs:
|
||||
- querier_json_body
|
||||
- querier_skip_resource_fingerprint
|
||||
- ttl
|
||||
- clickhousecluster
|
||||
- metricreduction
|
||||
sqlstore-provider:
|
||||
- postgres
|
||||
- sqlite
|
||||
|
||||
3
frontend/__mocks__/lib/env.ts
Normal file
3
frontend/__mocks__/lib/env.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const IS_DEV = false;
|
||||
export const IS_PROD = true;
|
||||
export const MODE = 'test';
|
||||
@@ -29,6 +29,7 @@ const config: Config.InitialOptions = {
|
||||
'^constants/env$': '<rootDir>/__mocks__/env.ts',
|
||||
'^src/constants/env$': '<rootDir>/__mocks__/env.ts',
|
||||
'^@signozhq/icons$': '<rootDir>/__mocks__/signozhqIconsMock.tsx',
|
||||
'^lib/env$': '<rootDir>/__mocks__/lib/env.ts',
|
||||
'^test-mocks/(.*)$': '<rootDir>/__mocks__/$1',
|
||||
'^react-syntax-highlighter/dist/esm/(.*)$':
|
||||
'<rootDir>/node_modules/react-syntax-highlighter/dist/cjs/$1',
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"event-source-polyfill": "1.0.31",
|
||||
"eventemitter3": "5.0.1",
|
||||
"history": "4.10.1",
|
||||
"html-to-image": "1.11.13",
|
||||
"http-status-codes": "2.3.0",
|
||||
"i18next": "^21.6.12",
|
||||
"i18next-browser-languagedetector": "^6.1.3",
|
||||
|
||||
8
frontend/pnpm-lock.yaml
generated
8
frontend/pnpm-lock.yaml
generated
@@ -164,6 +164,9 @@ importers:
|
||||
history:
|
||||
specifier: 4.10.1
|
||||
version: 4.10.1
|
||||
html-to-image:
|
||||
specifier: 1.11.13
|
||||
version: 1.11.13
|
||||
http-status-codes:
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0
|
||||
@@ -5451,6 +5454,9 @@ packages:
|
||||
resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==}
|
||||
engines: {node: '>=20.10'}
|
||||
|
||||
html-to-image@1.11.13:
|
||||
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
@@ -14495,6 +14501,8 @@ snapshots:
|
||||
|
||||
html-tags@5.1.0: {}
|
||||
|
||||
html-to-image@1.11.13: {}
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
http-proxy-agent@5.0.0:
|
||||
|
||||
@@ -62,6 +62,6 @@
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
}
|
||||
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
|
||||
}
|
||||
@@ -87,6 +87,6 @@
|
||||
"TRACES_FUNNELS_DETAIL": "SigNoz | Funnel",
|
||||
"INTEGRATIONS_DETAIL": "SigNoz | Integration",
|
||||
"PUBLIC_DASHBOARD": "SigNoz | Dashboard",
|
||||
"LLM_OBSERVABILITY_BASE": "SigNoz | LLM Observability",
|
||||
"LLM_OBSERVABILITY_MODEL_PRICING": "SigNoz | Model Pricing"
|
||||
}
|
||||
"LLM_OBSERVABILITY_OVERVIEW": "SigNoz | LLM Observability Overview",
|
||||
"LLM_OBSERVABILITY_CONFIGURATION": "SigNoz | LLM Observability Configuration"
|
||||
}
|
||||
@@ -329,10 +329,3 @@ export const LLMObservabilityPage = Loadable(
|
||||
/* webpackChunkName: "LLM Observability Page" */ 'pages/LLMObservability'
|
||||
),
|
||||
);
|
||||
|
||||
export const LLMObservabilityModelPricingPage = Loadable(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "LLM Observability Model Pricing Page" */ 'pages/LLMObservabilityModelPricing'
|
||||
),
|
||||
);
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
LicensePage,
|
||||
ListAllALertsPage,
|
||||
LLMObservabilityPage,
|
||||
LLMObservabilityModelPricingPage,
|
||||
LiveLogs,
|
||||
Login,
|
||||
Logs,
|
||||
@@ -515,17 +514,24 @@ const routes: AppRoutes[] = [
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_BASE,
|
||||
path: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
exact: true,
|
||||
component: LLMObservabilityPage,
|
||||
key: 'LLM_OBSERVABILITY_BASE',
|
||||
key: 'LLM_OBSERVABILITY_ATTRIBUTE_MAPPING',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
path: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
exact: true,
|
||||
component: LLMObservabilityModelPricingPage,
|
||||
key: 'LLM_OBSERVABILITY_MODEL_PRICING',
|
||||
component: LLMObservabilityPage,
|
||||
key: 'LLM_OBSERVABILITY_OVERVIEW',
|
||||
isPrivate: true,
|
||||
},
|
||||
{
|
||||
path: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
exact: true,
|
||||
component: LLMObservabilityPage,
|
||||
key: 'LLM_OBSERVABILITY_CONFIGURATION',
|
||||
isPrivate: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -269,3 +269,170 @@ export function mockQueryRangeV5WithEventsResponse({
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export type MockTracesOptions = {
|
||||
offset?: number;
|
||||
pageSize?: number;
|
||||
hasMore?: boolean;
|
||||
delay?: number;
|
||||
customTraces?: Array<{
|
||||
serviceName?: string;
|
||||
name?: string;
|
||||
durationNano?: number;
|
||||
httpMethod?: string;
|
||||
responseStatusCode?: string;
|
||||
}>;
|
||||
onReceiveRequest?: (
|
||||
req: RestRequest,
|
||||
) =>
|
||||
| undefined
|
||||
| void
|
||||
| Omit<MockTracesOptions, 'onReceiveRequest'>
|
||||
| Promise<Omit<MockTracesOptions, 'onReceiveRequest'>>
|
||||
| Promise<void>;
|
||||
};
|
||||
|
||||
const createTracesResponse = ({
|
||||
offset = 0,
|
||||
pageSize = 10,
|
||||
hasMore = true,
|
||||
customTraces,
|
||||
}: MockTracesOptions): MetricRangePayloadV5 => {
|
||||
const serviceNames = ['frontend', 'backend', 'database', 'api-gateway'];
|
||||
const spanNames = [
|
||||
'GET /api/users',
|
||||
'POST /api/orders',
|
||||
'SELECT * FROM users',
|
||||
];
|
||||
const httpMethods = ['GET', 'POST', 'PUT', 'DELETE'];
|
||||
const statusCodes = ['200', '201', '400', '404', '500'];
|
||||
|
||||
const rows = customTraces
|
||||
? customTraces.map((trace, index) => {
|
||||
const baseTimestamp = new Date('2026-04-21T17:54:33Z').getTime();
|
||||
const currentTimestamp = new Date(baseTimestamp - index * 60000);
|
||||
return {
|
||||
timestamp: currentTimestamp.toISOString(),
|
||||
data: {
|
||||
serviceName:
|
||||
trace.serviceName ?? serviceNames[index % serviceNames.length],
|
||||
name: trace.name ?? spanNames[index % spanNames.length],
|
||||
durationNano: trace.durationNano ?? 1000000 + index * 100000,
|
||||
httpMethod: trace.httpMethod ?? httpMethods[index % httpMethods.length],
|
||||
responseStatusCode:
|
||||
trace.responseStatusCode ?? statusCodes[index % statusCodes.length],
|
||||
traceID: `trace-id-${index}`,
|
||||
spanID: `span-id-${index}`,
|
||||
},
|
||||
};
|
||||
})
|
||||
: Array.from(
|
||||
{ length: hasMore ? pageSize : Math.ceil(pageSize / 2) },
|
||||
(_, index) => {
|
||||
const cumulativeIndex = offset + index;
|
||||
const baseTimestamp = new Date('2026-04-21T17:54:33Z').getTime();
|
||||
const currentTimestamp = new Date(baseTimestamp - cumulativeIndex * 60000);
|
||||
return {
|
||||
timestamp: currentTimestamp.toISOString(),
|
||||
data: {
|
||||
serviceName: serviceNames[cumulativeIndex % serviceNames.length],
|
||||
name: spanNames[cumulativeIndex % spanNames.length],
|
||||
durationNano: 1000000 + cumulativeIndex * 100000,
|
||||
httpMethod: httpMethods[cumulativeIndex % httpMethods.length],
|
||||
responseStatusCode: statusCodes[cumulativeIndex % statusCodes.length],
|
||||
traceID: `trace-id-${cumulativeIndex}`,
|
||||
spanID: `span-id-${cumulativeIndex}`,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
data: {
|
||||
type: 'raw',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
queryName: 'A',
|
||||
nextCursor: hasMore ? 'next-cursor-token' : '',
|
||||
rows,
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: {
|
||||
bytesScanned: 9682976,
|
||||
durationMs: 295,
|
||||
rowsScanned: 34198,
|
||||
stepIntervals: { A: 170 },
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export function mockQueryRangeV5WithTracesResponse({
|
||||
hasMore = true,
|
||||
offset = 0,
|
||||
pageSize = 10,
|
||||
delay = 0,
|
||||
customTraces,
|
||||
onReceiveRequest,
|
||||
}: MockTracesOptions = {}): void {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, async (req, res, ctx) =>
|
||||
res(
|
||||
...(delay ? [ctx.delay(delay)] : []),
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
createTracesResponse(
|
||||
(await onReceiveRequest?.(req)) ?? {
|
||||
hasMore,
|
||||
pageSize,
|
||||
offset,
|
||||
customTraces,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function mockQueryRangeV5WithEmptyTraces(): void {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: {
|
||||
type: 'raw',
|
||||
data: {
|
||||
results: [{ queryName: 'A', nextCursor: '', rows: [] }],
|
||||
},
|
||||
meta: {
|
||||
bytesScanned: 0,
|
||||
durationMs: 10,
|
||||
rowsScanned: 0,
|
||||
stepIntervals: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function mockQueryRangeV5WithKeyNotFoundError(): void {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(400),
|
||||
ctx.json({
|
||||
error: {
|
||||
code: 'invalid_input',
|
||||
errors: [{ message: 'key not found' }],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ type BadgeColor =
|
||||
|
||||
interface HttpStatusBadgeProps {
|
||||
statusCode: string | number;
|
||||
testId?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function getStatusCodeColor(statusCode: number): BadgeColor {
|
||||
@@ -35,6 +37,8 @@ function getStatusCodeColor(statusCode: number): BadgeColor {
|
||||
|
||||
function HttpStatusBadge({
|
||||
statusCode,
|
||||
testId,
|
||||
className,
|
||||
}: HttpStatusBadgeProps): JSX.Element | null {
|
||||
const numericStatusCode = Number(statusCode);
|
||||
|
||||
@@ -45,7 +49,12 @@ function HttpStatusBadge({
|
||||
const color = getStatusCodeColor(numericStatusCode);
|
||||
|
||||
return (
|
||||
<Badge color={color} variant="outline">
|
||||
<Badge
|
||||
color={color}
|
||||
variant="outline"
|
||||
data-testid={testId}
|
||||
className={className}
|
||||
>
|
||||
{statusCode}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
padding: 0px 8px;
|
||||
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
|
||||
background: var(--input-with-label-background-color, var(--l2-background));
|
||||
color: var(--input-with-label-color, var(--l2-foreground));
|
||||
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
@@ -35,21 +35,54 @@
|
||||
min-width: 150px;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
--input-border-radius: 0px;
|
||||
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
|
||||
background: var(--input-with-label-background-color, var(--l2-background));
|
||||
color: var(--input-with-label-color, var(--l2-foreground));
|
||||
|
||||
border-right: none;
|
||||
border-left: none;
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
font-size: 12px !important;
|
||||
line-height: 27px;
|
||||
line-height: 25px;
|
||||
|
||||
position: relative;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ant-select-selector {
|
||||
position: relative;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.ant-select:hover .ant-select-selector,
|
||||
.ant-select-focused .ant-select-selector {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&.input__has-label-after {
|
||||
margin-left: -1px;
|
||||
|
||||
.ant-select-selector {
|
||||
margin-left: -1px;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.input__has-close-button {
|
||||
.ant-select-selector {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
~ .close-btn {
|
||||
margin-left: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l2-foreground) !important;
|
||||
color: var(--input-with-label-color, var(--l3-foreground)) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
&[type='number']::-webkit-inner-spin-button,
|
||||
@@ -63,25 +96,35 @@
|
||||
|
||||
.close-btn {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
height: 38px;
|
||||
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
|
||||
background: var(--input-with-label-background-color, var(--l2-background));
|
||||
height: 100%;
|
||||
width: 38px;
|
||||
position: relative;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
&.labelAfter {
|
||||
.input {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--input-with-label-border-color, var(--l2-border));
|
||||
background: var(--input-with-label-background-color, var(--l2-background));
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
|
||||
.ant-select-selector {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
border-left: none;
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,10 @@ function InputWithLabel({
|
||||
>
|
||||
{!labelAfter && <Typography.Text className="label">{label}</Typography.Text>}
|
||||
<Input
|
||||
className="input"
|
||||
className={cx('input', {
|
||||
'input__has-label-after': !labelAfter,
|
||||
'input__has-close-button': !!onClose,
|
||||
})}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
value={inputValue}
|
||||
|
||||
@@ -80,8 +80,8 @@
|
||||
width: 1px;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--l1-border),
|
||||
var(--l1-border) 4px,
|
||||
var(--query-builder-v2-border-color, var(--l2-border)),
|
||||
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
@@ -101,7 +101,8 @@
|
||||
top: 12px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-left: 6px dotted var(--l1-border);
|
||||
border-left: 6px dotted
|
||||
var(--query-builder-v2-border-color, var(--l2-border));
|
||||
}
|
||||
|
||||
/* Horizontal line pointing from vertical to the item */
|
||||
@@ -114,8 +115,8 @@
|
||||
height: 1px;
|
||||
background: repeating-linear-gradient(
|
||||
to right,
|
||||
var(--l1-border),
|
||||
var(--l1-border) 4px,
|
||||
var(--query-builder-v2-border-color, var(--l2-border)),
|
||||
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
@@ -241,7 +242,8 @@
|
||||
top: 12px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-left: 6px dotted var(--l1-border);
|
||||
border-left: 6px dotted
|
||||
var(--query-builder-v2-border-color, var(--l2-border));
|
||||
}
|
||||
|
||||
/* Horizontal line pointing from vertical to the item */
|
||||
@@ -254,8 +256,8 @@
|
||||
height: 1px;
|
||||
background: repeating-linear-gradient(
|
||||
to right,
|
||||
var(--l1-border),
|
||||
var(--l1-border) 4px,
|
||||
var(--query-builder-v2-border-color, var(--l2-border)),
|
||||
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
@@ -273,6 +275,16 @@
|
||||
line-height: 16px; /* 128.571% */
|
||||
|
||||
resize: none;
|
||||
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
|
||||
&:placeholder {
|
||||
color: var(--query-builder-v2-placeholder-color, var(--l3-foreground));
|
||||
}
|
||||
}
|
||||
|
||||
.formula-legend {
|
||||
@@ -282,15 +294,42 @@
|
||||
.ant-input-group-addon {
|
||||
border-top-left-radius: 0px !important;
|
||||
border-top-right-radius: 0px !important;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
background: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
);
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
border-top-left-radius: 0px !important;
|
||||
border-top-right-radius: 0px !important;
|
||||
|
||||
height: 36px;
|
||||
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
|
||||
position: relative;
|
||||
margin-left: -1px;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
z-index: 1;
|
||||
border-color: var(--internal-ant-border-color-hover);
|
||||
}
|
||||
|
||||
&:placeholder {
|
||||
color: var(--query-builder-v2-placeholder-color, var(--l3-foreground));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -323,8 +362,8 @@
|
||||
width: 1px;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--l1-border),
|
||||
var(--l1-border) 4px,
|
||||
var(--query-builder-v2-border-color, var(--l2-border)),
|
||||
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
@@ -395,8 +434,8 @@
|
||||
width: 1px;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--l1-border),
|
||||
var(--l1-border) 4px,
|
||||
var(--query-builder-v2-border-color, var(--l2-border)),
|
||||
var(--query-builder-v2-border-color, var(--l2-border)) 4px,
|
||||
transparent 4px,
|
||||
transparent 8px
|
||||
);
|
||||
@@ -412,7 +451,7 @@
|
||||
min-width: 120px;
|
||||
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
background: var(--l1-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@@ -457,13 +496,16 @@
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l1-background) !important;
|
||||
height: 34px !important;
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border)) !important;
|
||||
background: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
height: 36px !important;
|
||||
box-sizing: border-box !important;
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--l1-foreground);
|
||||
color: var(--query-builder-v2-color, var(--l1-foreground));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,11 @@
|
||||
|
||||
.ant-select {
|
||||
width: 100%;
|
||||
|
||||
.ant-select-selector {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.ant-select-selection-search-input {
|
||||
min-width: max-content !important;
|
||||
max-width: 100% !important;
|
||||
@@ -100,9 +105,12 @@
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 2px;
|
||||
border: 1.005px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border)) !important;
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
@@ -123,6 +131,7 @@
|
||||
.input {
|
||||
flex: initial;
|
||||
width: 100px !important;
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
.ant-select-selection-search-input {
|
||||
font-size: 12px !important;
|
||||
line-height: 27px;
|
||||
line-height: 25px;
|
||||
&::placeholder {
|
||||
color: var(--l2-foreground) !important;
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,12 @@
|
||||
.ant-select-selector {
|
||||
width: 100%;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border)) !important;
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
@@ -33,36 +36,49 @@
|
||||
min-height: 36px;
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
color: var(--l2-foreground) !important;
|
||||
color: var(
|
||||
--query-builder-v2-placeholder-color,
|
||||
var(--l3-foreground)
|
||||
) !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-dropdown {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
.qb-select-popover.ant-select-dropdown {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
background: var(--query-builder-v2-background-color, var(--l2-background));
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
.ant-select-item {
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
.ant-select-item {
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
|
||||
&:hover,
|
||||
&.ant-select-item-option-active {
|
||||
background: var(--l3-background) !important;
|
||||
}
|
||||
&:not(:last-of-type) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&.ant-select-item-option-selected {
|
||||
background: var(--l3-background) !important;
|
||||
border: 1px solid var(--l1-border);
|
||||
font-weight: 600;
|
||||
}
|
||||
&:hover,
|
||||
&.ant-select-item-option-active {
|
||||
background: var(
|
||||
--query-builder-v2-selected-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
}
|
||||
|
||||
&.ant-select-item-option-selected {
|
||||
background: var(
|
||||
--query-builder-v2-selected-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@ export const MetricsSelect = memo(function MetricsSelect({
|
||||
{signalSourceChangeEnabled && (
|
||||
<Select
|
||||
className="source-selector"
|
||||
popupClassName="qb-select-popover"
|
||||
placeholder="Source"
|
||||
options={SOURCE_OPTIONS}
|
||||
value={source}
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
// TODO: Improve the styling of the query aggregation container and its components. - @YounixM , @H4ad
|
||||
|
||||
.query-add-ons {
|
||||
width: 100%;
|
||||
|
||||
--toggle-group-secondary-bg: var(
|
||||
--query-builder-v2-toggle-group-background-color,
|
||||
var(--l1-background-hover)
|
||||
);
|
||||
--toggle-group-secondary-border: var(
|
||||
--query-builder-v2-toggle-group-border-color,
|
||||
var(--l2-border)
|
||||
);
|
||||
--toggle-group-secondary-active-bg: var(
|
||||
--query-builder-v2-toggle-group-active-background-color,
|
||||
var(--l1-background)
|
||||
);
|
||||
--toggle-group-secondary-bg-hover: var(
|
||||
--query-builder-v2-toggle-group-background-color-hover,
|
||||
var(--l2-background)
|
||||
);
|
||||
|
||||
.add-on-tab-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -29,32 +44,33 @@
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
|
||||
color: var(--l2-foreground);
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
}
|
||||
|
||||
> button {
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
border-left: none;
|
||||
min-width: 120px;
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
|
||||
&:first-child {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
border-left: 1px solid
|
||||
var(--query-builder-v2-border-color, var(--l2-border));
|
||||
}
|
||||
|
||||
&::before {
|
||||
background: var(--l1-border);
|
||||
background: var(--query-builder-v2-border-color, var(--l2-border));
|
||||
}
|
||||
|
||||
&[data-state='on'] {
|
||||
color: var(--text-robin-500);
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
|
||||
display: none;
|
||||
|
||||
&::before {
|
||||
background: var(--l1-border);
|
||||
background: var(--query-builder-v2-border-color, var(--l2-border));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +81,7 @@
|
||||
height: 30px;
|
||||
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
background: var(--l3-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -78,10 +94,13 @@
|
||||
align-items: center;
|
||||
|
||||
.having-filter-select-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
background: var(--query-builder-v2-background-color, var(--l2-background));
|
||||
padding-right: 38px;
|
||||
|
||||
.having-filter-select-editor {
|
||||
border-radius: 2px;
|
||||
@@ -106,15 +125,17 @@
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
border-left-width: 0px;
|
||||
border-right-width: 0px;
|
||||
padding: 0px !important;
|
||||
background-color: var(--l2-background) !important;
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--l1-border);
|
||||
border-color: var(--query-builder-v2-border-color, var(--l2-border));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,17 +239,32 @@
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
line-height: 36px !important;
|
||||
min-height: 34px;
|
||||
line-height: 32px !important;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
background-color: var(--l2-background) !important;
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
|
||||
&,
|
||||
.ͼ1a {
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
@@ -237,8 +273,11 @@
|
||||
}
|
||||
|
||||
.chip-decorator {
|
||||
background: var(--l3-background) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
background: var(
|
||||
--query-builder-v2-chip-decorator-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
margin-right: 4px;
|
||||
@@ -246,34 +285,38 @@
|
||||
}
|
||||
|
||||
.cm-selectionBackground {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
.cm-activeLine > span {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.cm-placeholder {
|
||||
color: var(
|
||||
--query-builder-v2-placeholder-color,
|
||||
var(--l3-foreground)
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
height: 38px;
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
background: var(--query-builder-v2-background-color, var(--l2-background));
|
||||
height: 100%;
|
||||
width: 38px;
|
||||
|
||||
border-left: transparent;
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
|
||||
&:focus:not(:focus-visible),
|
||||
&.ant-btn:focus:not(:focus-visible) {
|
||||
border-color: var(--l2-border);
|
||||
border-left-color: transparent;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -300,20 +343,8 @@
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
$add-on-row-height: 38px;
|
||||
|
||||
.periscope-input-with-label {
|
||||
.input {
|
||||
.ant-select {
|
||||
height: $add-on-row-height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-with-label {
|
||||
.input {
|
||||
height: $add-on-row-height;
|
||||
}
|
||||
input {
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground) !important;
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
|
||||
|
||||
&.error {
|
||||
.cm-editor {
|
||||
@@ -51,14 +51,15 @@
|
||||
|
||||
.cm-content {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
padding: 0px !important;
|
||||
background-color: var(--l1-background) !important;
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--l1-border);
|
||||
border-color: var(--query-builder-v2-border-color, var(--l2-border));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +75,7 @@
|
||||
right: 0px !important;
|
||||
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
color-mix(in srgb, var(--card) 80%, transparent) 0%,
|
||||
@@ -118,7 +119,7 @@
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
color: var(--l2-foreground) !important;
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
|
||||
.cm-completionIcon {
|
||||
@@ -127,7 +128,10 @@
|
||||
|
||||
&:hover,
|
||||
&[aria-selected='true'] {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
@@ -142,15 +146,24 @@
|
||||
.cm-line {
|
||||
line-height: 36px !important;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
background-color: var(--l2-background) !important;
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
|
||||
::-moz-selection {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
@@ -159,8 +172,11 @@
|
||||
}
|
||||
|
||||
.chip-decorator {
|
||||
background: var(--l3-background) !important;
|
||||
color: var(--l1-foreground) !important;
|
||||
background: var(
|
||||
--query-builder-v2-chip-decorator-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
color: var(--query-builder-v2-color, var(--l1-foreground)) !important;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
margin-right: 4px;
|
||||
@@ -168,7 +184,10 @@
|
||||
}
|
||||
|
||||
.cm-selectionBackground {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
}
|
||||
@@ -201,12 +220,11 @@
|
||||
|
||||
.close-btn {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
height: 38px;
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
background: var(--query-builder-v2-background-color, var(--l2-background));
|
||||
height: 100%;
|
||||
width: 38px;
|
||||
|
||||
border-left: transparent;
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
}
|
||||
@@ -217,13 +235,13 @@
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l1-foreground);
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
@@ -238,9 +256,10 @@
|
||||
.query-aggregation-interval-input-container {
|
||||
.query-aggregation-interval-input {
|
||||
input {
|
||||
min-height: 36px;
|
||||
max-width: 120px;
|
||||
&::placeholder {
|
||||
color: var(--l2-foreground);
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -251,8 +270,8 @@
|
||||
|
||||
.query-aggregation-error-popover {
|
||||
.ant-popover-inner {
|
||||
background-color: var(--l1-border);
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--query-builder-v2-border-color, var(--l2-border));
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
border-radius: 4px;
|
||||
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.add-trace-operator-button,
|
||||
.add-new-query-button,
|
||||
.add-formula-button {
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
background: var(--query-builder-v2-background-color, var(--l2-background));
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@@ -40,11 +40,14 @@ $max-recents-shown: 5;
|
||||
.query-status-container {
|
||||
width: 32px;
|
||||
|
||||
background-color: var(--l1-background) !important;
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
border-radius: 2px;
|
||||
border-top-left-radius: 0px !important;
|
||||
border-bottom-left-radius: 0px !important;
|
||||
@@ -83,16 +86,16 @@ $max-recents-shown: 5;
|
||||
|
||||
.cm-content {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
padding: 0px !important;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--l1-border);
|
||||
border-color: var(--query-builder-v2-border-color, var(--l2-border));
|
||||
}
|
||||
}
|
||||
|
||||
&.cm-focused {
|
||||
outline: 1px solid var(--l1-border);
|
||||
outline: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete {
|
||||
@@ -183,11 +186,17 @@ $max-recents-shown: 5;
|
||||
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
|
||||
background-color: var(--l1-background) !important;
|
||||
color: var(--l2-foreground) !important;
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground)) !important;
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
}
|
||||
|
||||
.cm-completionIcon {
|
||||
@@ -205,7 +214,10 @@ $max-recents-shown: 5;
|
||||
}
|
||||
|
||||
&[aria-selected='true'] {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
}
|
||||
@@ -274,25 +286,49 @@ $max-recents-shown: 5;
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
line-height: 34px !important;
|
||||
line-height: 36px !important;
|
||||
font-family: 'Space Mono', monospace !important;
|
||||
background-color: var(--l2-background) !important;
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
|
||||
&,
|
||||
.ͼ1a {
|
||||
color: var(--query-builder-v2-color, var(--l2-foreground));
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-selectionBackground {
|
||||
background: var(--l3-background) !important;
|
||||
background: var(
|
||||
--query-builder-v2-selection-background-color,
|
||||
var(--l3-background)
|
||||
) !important;
|
||||
opacity: 0.5 !important;
|
||||
}
|
||||
|
||||
.cm-placeholder {
|
||||
color: var(
|
||||
--query-builder-v2-placeholder-color,
|
||||
var(--l3-foreground)
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.cursor-position {
|
||||
|
||||
@@ -65,6 +65,14 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
}
|
||||
|
||||
&-aggregation-container {
|
||||
|
||||
@@ -47,7 +47,7 @@ function CheckboxValueRow({
|
||||
{customRendererForValue ? (
|
||||
customRendererForValue(value)
|
||||
) : (
|
||||
<TooltipSimple title={value} side="top" align="center" arrow>
|
||||
<TooltipSimple title={String(value)} side="top" align="start">
|
||||
<Typography.Text className="value-string" truncate={1}>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -57,10 +57,13 @@ function TimelineV3(props: ITimelineV3Props): JSX.Element {
|
||||
}
|
||||
|
||||
const timeAtCursor = offsetTimestamp + cursorXPercent * spread;
|
||||
const unit = getIntervalUnit(spread, offsetTimestamp);
|
||||
// Use the same width-derived interval spread the ticks use, so the badge
|
||||
// unit always matches the tick unit (narrow rulers pick fewer intervals).
|
||||
const intervalSpread = spread / getMinimumIntervalsBasedOnWidth(width);
|
||||
const unit = getIntervalUnit(intervalSpread, offsetTimestamp);
|
||||
const formatted = toFixed(resolveTimeFromInterval(timeAtCursor, unit), 2);
|
||||
return `${formatted}${unit.name}`;
|
||||
}, [cursorXPercent, spread, offsetTimestamp]);
|
||||
}, [cursorXPercent, spread, offsetTimestamp, width]);
|
||||
|
||||
if (endTimestamp < startTimestamp) {
|
||||
console.error(
|
||||
@@ -94,12 +97,17 @@ function TimelineV3(props: ITimelineV3Props): JSX.Element {
|
||||
>
|
||||
<text
|
||||
x={index === intervals.length - 1 ? -10 : 0}
|
||||
y={timelineHeight * 2}
|
||||
y={timelineHeight * 2 - 3}
|
||||
fill={strokeColor}
|
||||
>
|
||||
{interval.label}
|
||||
</text>
|
||||
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
|
||||
<line
|
||||
y1={0}
|
||||
y2={timelineHeight - 3}
|
||||
stroke={strokeColor}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
63
frontend/src/components/TimelineV3/__tests__/utils.test.ts
Normal file
63
frontend/src/components/TimelineV3/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
getIntervals,
|
||||
getIntervalUnit,
|
||||
getMinimumIntervalsBasedOnWidth,
|
||||
} from '../utils';
|
||||
|
||||
// A tick label looks like "200.00ms" / "1.10s" — grab the trailing unit name.
|
||||
function unitOfLabel(label: string): string {
|
||||
const match = label.match(/[a-z]+$/i);
|
||||
return match ? match[0] : '';
|
||||
}
|
||||
|
||||
describe('getMinimumIntervalsBasedOnWidth', () => {
|
||||
it('returns fewer intervals for narrower rulers', () => {
|
||||
expect(getMinimumIntervalsBasedOnWidth(500)).toBe(3);
|
||||
expect(getMinimumIntervalsBasedOnWidth(700)).toBe(4);
|
||||
expect(getMinimumIntervalsBasedOnWidth(900)).toBe(5);
|
||||
expect(getMinimumIntervalsBasedOnWidth(1200)).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIntervalUnit', () => {
|
||||
it('selects the unit from the interval spread', () => {
|
||||
expect(getIntervalUnit(130, 0).name).toBe('ms');
|
||||
expect(getIntervalUnit(1100, 0).name).toBe('s');
|
||||
expect(getIntervalUnit(70_000, 0).name).toBe('m');
|
||||
});
|
||||
|
||||
it('accounts for a large offset (deep-zoom labels stay readable)', () => {
|
||||
// Cursor spread is tiny but the window starts 5,000,000ms into the trace.
|
||||
expect(getIntervalUnit(100, 5_000_000).name).toBe('hr');
|
||||
});
|
||||
|
||||
// Regression: the interval COUNT changes the chosen unit, so the crosshair
|
||||
// badge must use the same width-derived count as the ticks. On a 5.5s trace a
|
||||
// narrow ruler (5 intervals → 1100ms → "s") and a wide one (6 intervals →
|
||||
// 916ms → "ms") pick different units.
|
||||
it('can resolve to different units for the same spread at different counts', () => {
|
||||
const spread = 5500;
|
||||
expect(getIntervalUnit(spread / 5, 0).name).toBe('s');
|
||||
expect(getIntervalUnit(spread / 6, 0).name).toBe('ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('badge/tick unit consistency', () => {
|
||||
// The invariant the fix guarantees: when the badge and the ticks are fed the
|
||||
// same width-derived intervalSpread, every tick label uses the badge's unit.
|
||||
it.each([
|
||||
{ spread: 1287, width: 900 },
|
||||
{ spread: 5500, width: 900 }, // narrow → 5 intervals, the mismatch case
|
||||
{ spread: 5500, width: 1200 }, // wide → 6 intervals
|
||||
{ spread: 120_000, width: 700 },
|
||||
])('spread=$spread width=$width', ({ spread, width }) => {
|
||||
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
|
||||
const intervalSpread = spread / minIntervals;
|
||||
const badgeUnit = getIntervalUnit(intervalSpread, 0).name;
|
||||
const intervals = getIntervals(intervalSpread, spread, 0);
|
||||
|
||||
intervals.forEach((interval) => {
|
||||
expect(unitOfLabel(interval.label)).toBe(badgeUnit);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,14 +10,18 @@ export type { Interval };
|
||||
|
||||
/**
|
||||
* Select the interval unit matching the timeline's logic.
|
||||
* Exported so crosshair labels use the same unit as timeline ticks.
|
||||
* Exported so crosshair labels use the same unit as the timeline ticks.
|
||||
*
|
||||
* Takes the already-computed `intervalSpread` (spread / minIntervals) rather
|
||||
* than deriving it from a hardcoded interval count — the tick count is
|
||||
* width-dependent (`getMinimumIntervalsBasedOnWidth`), so callers must pass the
|
||||
* same `intervalSpread` the ticks use or the badge unit can diverge from the
|
||||
* ticks (e.g. ms vs s) on narrower rulers like the waterfall.
|
||||
*/
|
||||
export function getIntervalUnit(
|
||||
spread: number,
|
||||
intervalSpread: number,
|
||||
offsetTimestamp: number,
|
||||
): IIntervalUnit {
|
||||
const minIntervals = 6;
|
||||
const intervalSpread = spread / minIntervals;
|
||||
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
|
||||
let unit: IIntervalUnit = INTERVAL_UNITS[0];
|
||||
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* Overlay stays below content */
|
||||
[data-slot='dialog-overlay'] {
|
||||
z-index: 50;
|
||||
z-index: 1000 !important;
|
||||
}
|
||||
|
||||
/* Dialog content always above overlay */
|
||||
[data-slot='dialog-content'] {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
z-index: 1001 !important;
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useThemeMode } from 'hooks/useDarkMode';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { IS_DEV } from 'lib/env';
|
||||
import history from 'lib/history';
|
||||
import { ROLES as UserRole } from 'types/roles';
|
||||
|
||||
@@ -30,6 +31,33 @@ import { useCmdK } from '../../providers/cmdKProvider';
|
||||
|
||||
import './cmdKPalette.scss';
|
||||
|
||||
const AuthZDevModal = IS_DEV
|
||||
? React.lazy(() =>
|
||||
import('lib/authz/devtools/AuthZDevModal/AuthZDevModal').then((m) => ({
|
||||
default: m.AuthZDevModal,
|
||||
})),
|
||||
)
|
||||
: null;
|
||||
|
||||
const AuthZDevFloatingIndicator = IS_DEV
|
||||
? React.lazy(() =>
|
||||
import('lib/authz/devtools/AuthZDevFloatingIndicator/AuthZDevFloatingIndicator').then(
|
||||
(m) => ({
|
||||
default: m.AuthZDevFloatingIndicator,
|
||||
}),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
const openAuthZDevModal = IS_DEV
|
||||
? (): void => {
|
||||
void import('lib/authz/devtools/useAuthZDevStore').then((m) => {
|
||||
m.openAuthZDevModal();
|
||||
return m;
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
|
||||
type CmdAction = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -110,6 +138,7 @@ export function CmdKPalette({
|
||||
aiAssistant: isAIAssistantEnabled
|
||||
? { open: handleOpenAIAssistant }
|
||||
: undefined,
|
||||
authzDevTools: openAuthZDevModal ? { open: openAuthZDevModal } : undefined,
|
||||
});
|
||||
|
||||
// RBAC filter: show action if no roles set OR current user role is included
|
||||
@@ -146,37 +175,57 @@ export function CmdKPalette({
|
||||
};
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={setOpen} position="top" offset={110}>
|
||||
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
|
||||
<CommandList className="cmdk-list-scroll">
|
||||
<CommandEmpty>No results</CommandEmpty>
|
||||
{grouped.map(([section, items]) => (
|
||||
<CommandGroup
|
||||
key={section}
|
||||
heading={section}
|
||||
className="cmdk-section-heading"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<CommandItem
|
||||
key={it.id}
|
||||
onSelect={(): void => handleInvoke(it)}
|
||||
value={it.name}
|
||||
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
|
||||
>
|
||||
<span
|
||||
className={cx('cmd-item-icon', it.id === 'ai-assistant' && 'noz-icon')}
|
||||
<>
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
position="top"
|
||||
offset={110}
|
||||
>
|
||||
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
|
||||
<CommandList className="cmdk-list-scroll">
|
||||
<CommandEmpty>No results</CommandEmpty>
|
||||
{grouped.map(([section, items]) => (
|
||||
<CommandGroup
|
||||
key={section}
|
||||
heading={section}
|
||||
className="cmdk-section-heading"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<CommandItem
|
||||
key={it.id}
|
||||
onSelect={(): void => handleInvoke(it)}
|
||||
value={it.name}
|
||||
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
|
||||
>
|
||||
{it.icon}
|
||||
</span>
|
||||
{it.name}
|
||||
{it.shortcut && it.shortcut.length > 0 && (
|
||||
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
<span
|
||||
className={cx(
|
||||
'cmd-item-icon',
|
||||
it.id === 'ai-assistant' && 'noz-icon',
|
||||
)}
|
||||
>
|
||||
{it.icon}
|
||||
</span>
|
||||
{it.name}
|
||||
{it.shortcut && it.shortcut.length > 0 && (
|
||||
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
{IS_DEV && AuthZDevModal && (
|
||||
<React.Suspense fallback={null}>
|
||||
<AuthZDevModal />
|
||||
</React.Suspense>
|
||||
)}
|
||||
{IS_DEV && AuthZDevFloatingIndicator && (
|
||||
<React.Suspense fallback={null}>
|
||||
<AuthZDevFloatingIndicator />
|
||||
</React.Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ export enum LOCALSTORAGE {
|
||||
SHOW_EXCEPTIONS_QUICK_FILTERS = 'SHOW_EXCEPTIONS_QUICK_FILTERS',
|
||||
QUICK_FILTERS_SETTINGS_ANNOUNCEMENT = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT',
|
||||
QUICK_FILTERS_WIDTH_LOGS = 'QUICK_FILTERS_WIDTH_LOGS',
|
||||
QUICK_FILTERS_WIDTH_TRACES = 'QUICK_FILTERS_WIDTH_TRACES',
|
||||
QUICK_FILTERS_WIDTH_METER = 'QUICK_FILTERS_WIDTH_METER',
|
||||
QUICK_FILTERS_WIDTH_API_MONITORING = 'QUICK_FILTERS_WIDTH_API_MONITORING',
|
||||
QUICK_FILTERS_WIDTH_EXCEPTIONS = 'QUICK_FILTERS_WIDTH_EXCEPTIONS',
|
||||
QUICK_FILTERS_WIDTH_INFRA = 'QUICK_FILTERS_WIDTH_INFRA',
|
||||
FUNNEL_STEPS = 'FUNNEL_STEPS',
|
||||
SPAN_DETAILS_PINNED_ATTRIBUTES = 'SPAN_DETAILS_PINNED_ATTRIBUTES',
|
||||
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',
|
||||
|
||||
@@ -89,8 +89,10 @@ const ROUTES = {
|
||||
AI_ASSISTANT_BASE: '/ai-assistant',
|
||||
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
|
||||
MCP_SERVER: '/settings/mcp-server',
|
||||
LLM_OBSERVABILITY_ATTRIBUTE_MAPPING: '/llm-observability/attribute-mapping',
|
||||
LLM_OBSERVABILITY_BASE: '/llm-observability',
|
||||
LLM_OBSERVABILITY_MODEL_PRICING: '/llm-observability/settings/model-pricing',
|
||||
LLM_OBSERVABILITY_OVERVIEW: '/llm-observability/overview',
|
||||
LLM_OBSERVABILITY_CONFIGURATION: '/llm-observability/configuration',
|
||||
} as const;
|
||||
|
||||
export default ROUTES;
|
||||
|
||||
@@ -43,10 +43,17 @@ type ActionDeps = {
|
||||
aiAssistant?: {
|
||||
open: () => void;
|
||||
};
|
||||
/**
|
||||
* Provided only in development mode. Opens the AuthZ DevTools modal
|
||||
* for testing permission overrides.
|
||||
*/
|
||||
authzDevTools?: {
|
||||
open: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
const { navigate, handleThemeChange, aiAssistant } = deps;
|
||||
const { navigate, handleThemeChange, aiAssistant, authzDevTools } = deps;
|
||||
|
||||
const actions: CmdAction[] = [
|
||||
{
|
||||
@@ -302,5 +309,17 @@ export function createShortcutActions(deps: ActionDeps): CmdAction[] {
|
||||
});
|
||||
}
|
||||
|
||||
if (authzDevTools) {
|
||||
actions.push({
|
||||
id: 'authz-devtools',
|
||||
name: 'AuthZ DevTools',
|
||||
keywords: 'authz permissions rbac debug devtools override testing',
|
||||
section: 'Dev',
|
||||
icon: <Settings size={14} />,
|
||||
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
perform: authzDevTools.open,
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
@@ -163,12 +163,23 @@
|
||||
}
|
||||
|
||||
&.filter-visible {
|
||||
// Width is owned by ResizableBox (inline style); this section is the
|
||||
// ResizableBox root, so it stays position: relative for the drag handle.
|
||||
.api-quick-filter-left-section {
|
||||
width: 260px;
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.api-module-right-section {
|
||||
width: calc(100% - 260px);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,21 +4,49 @@ import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
|
||||
import DomainList from './Domains/DomainList';
|
||||
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 260;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
useEffect(() => {
|
||||
logEvent('API Monitoring: Landing page visited', {});
|
||||
}, []);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_API_MONITORING,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className={cx('api-monitoring-page', 'filter-visible')}>
|
||||
<section className="api-quick-filter-left-section">
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className="api-quick-filter-left-section"
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-api-monitoring"
|
||||
source={QuickFiltersSource.API_MONITORING}
|
||||
@@ -27,7 +55,7 @@ function Explorer(): JSX.Element {
|
||||
showQueryName={false}
|
||||
handleFilterVisibilityChange={(): void => {}}
|
||||
/>
|
||||
</section>
|
||||
</ResizableBox>
|
||||
<DomainList />
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
|
||||
@@ -41,6 +41,28 @@
|
||||
|
||||
.steps-container {
|
||||
width: 80%;
|
||||
|
||||
.alert-query-section-container {
|
||||
--query-builder-v2-color: var(--l2-foreground);
|
||||
--query-builder-v2-background-color: var(--l3-background);
|
||||
--query-builder-v2-border-color: var(--l3-border);
|
||||
--query-builder-v2-selection-background-color: var(--l2-background);
|
||||
--query-builder-v2-chip-decorator-background-color: var(--l2-background);
|
||||
--query-builder-v2-placeholder-color: var(--l3-foreground);
|
||||
--query-builder-v2-selected-background-color: var(--l3-foreground);
|
||||
--query-search-background-color: var(--l3-background);
|
||||
--query-search-background-color-selection: var(--l3-background);
|
||||
--input-with-label-border-color: var(--l3-border);
|
||||
--input-with-label-background-color: var(--l3-background);
|
||||
--input-with-label-color: var(--l2-foreground);
|
||||
--query-builder-v2-toggle-group-background-color: var(--l3-background);
|
||||
--query-builder-v2-toggle-group-border-color: var(--l3-border);
|
||||
--query-builder-v2-toggle-group-active-background-color: var(--l2-background);
|
||||
--query-builder-v2-toggle-group-background-color-hover: var(--l3-background);
|
||||
--input-height: 36px;
|
||||
--input-hover-background: var(--l3-background);
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.qb-chart-preview-container {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.wrapper :global(button[data-color]) {
|
||||
--checkbox-checked-background: var(--series-color);
|
||||
--checkbox-border-color: var(--series-color);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
import { CheckBoxProps } from '../types';
|
||||
import styles from './CustomCheckBox.module.scss';
|
||||
|
||||
function CustomCheckBox({
|
||||
data,
|
||||
@@ -15,12 +16,11 @@ function CustomCheckBox({
|
||||
const isChecked = graphVisibilityState[index] || false;
|
||||
|
||||
const colorStyle = {
|
||||
'--checkbox-checked-background': color,
|
||||
'--checkbox-border-color': color,
|
||||
'--series-color': color,
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<span style={colorStyle}>
|
||||
<span className={styles.wrapper} style={colorStyle}>
|
||||
<Checkbox
|
||||
onChange={(checked): void => checkBoxOnChangeHandler(checked, index)}
|
||||
value={isChecked}
|
||||
|
||||
@@ -3,6 +3,30 @@
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
||||
--query-builder-v2-color: var(--l2-foreground);
|
||||
--query-builder-v2-background-color: var(--l3-background);
|
||||
--query-builder-v2-border-color: var(--l3-border);
|
||||
--query-builder-v2-selection-background-color: var(--l2-background);
|
||||
--query-builder-v2-chip-decorator-background-color: var(--l2-background);
|
||||
--query-builder-v2-placeholder-color: var(--l3-foreground);
|
||||
--query-builder-v2-selected-background-color: var(--l3-foreground);
|
||||
--query-search-background-color: var(--l3-background);
|
||||
--query-search-background-color-selection: var(--l3-background);
|
||||
--input-with-label-border-color: var(--l3-border);
|
||||
--input-with-label-background-color: var(--l3-background);
|
||||
--input-with-label-color: var(--l2-foreground);
|
||||
--query-builder-v2-toggle-group-background-color: var(--l3-background);
|
||||
--query-builder-v2-toggle-group-border-color: var(--l3-border);
|
||||
--query-builder-v2-toggle-group-active-background-color: var(--l2-background);
|
||||
--query-builder-v2-toggle-group-background-color-hover: var(--l3-background);
|
||||
--input-height: 36px;
|
||||
--input-hover-background: var(--l3-background);
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-background: var(--l3-background);
|
||||
--input-focus-background: var(--l3-background);
|
||||
--input-border-color: var(--l3-border);
|
||||
|
||||
.full-view-header-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -66,6 +66,7 @@ function WidgetGraphComponent({
|
||||
customOnRowClick,
|
||||
customTimeRangeWindowForCoRelation,
|
||||
enableDrillDown,
|
||||
hidePagination,
|
||||
}: WidgetGraphComponentProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const [deleteModal, setDeleteModal] = useState(false);
|
||||
@@ -430,6 +431,7 @@ function WidgetGraphComponent({
|
||||
customSeries={customSeries}
|
||||
customOnRowClick={customOnRowClick}
|
||||
enableDrillDown={enableDrillDown}
|
||||
hidePagination={hidePagination}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface WidgetGraphComponentProps {
|
||||
customOnRowClick?: (record: RowData) => void;
|
||||
customTimeRangeWindowForCoRelation?: string | undefined;
|
||||
enableDrillDown?: boolean;
|
||||
/** Hide list-panel pagination controls (e.g. public dashboards, where paging isn't supported). */
|
||||
hidePagination?: boolean;
|
||||
}
|
||||
|
||||
export interface GridCardGraphProps {
|
||||
|
||||
@@ -6,6 +6,7 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import K8sBaseDetails from 'container/InfraMonitoringK8s/Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from 'container/InfraMonitoringK8s/Base/K8sBaseList';
|
||||
import { K8sBaseFilters } from 'container/InfraMonitoringK8s/Base/types';
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
} from 'container/InfraMonitoringK8s/hooks';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
@@ -40,8 +43,21 @@ import { getHostsQuickFiltersConfig } from './utils';
|
||||
import styles from './InfraMonitoringHosts.module.scss';
|
||||
import { ArrowUpToLine, Filter } from '@signozhq/icons';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Hosts(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
|
||||
@@ -139,7 +155,18 @@ function Hosts(): JSX.Element {
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className={styles.quickFiltersContainer}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<div className={styles.quickFiltersContainerHeader}>
|
||||
<Typography.Text>Filters</Typography.Text>
|
||||
<Tooltip title="Collapse Filters">
|
||||
@@ -156,7 +183,7 @@ function Hosts(): JSX.Element {
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleQuickFiltersChange}
|
||||
/>
|
||||
</div>
|
||||
</ResizableBox>
|
||||
)}
|
||||
<div
|
||||
className={`${styles.listContainer}${
|
||||
@@ -189,8 +216,6 @@ function Hosts(): JSX.Element {
|
||||
queryKeyPrefix="hosts"
|
||||
tabsConfig={{
|
||||
showEvents: false,
|
||||
showContainers: true,
|
||||
showProcesses: true,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -44,26 +44,20 @@
|
||||
}
|
||||
|
||||
.quickFiltersContainer {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
// Width is owned by ResizableBox (inline style). No overflow here: the panel
|
||||
// must not scroll (QuickFilters scrolls its own list internally) — an
|
||||
// overflow on the root would also clip the edge-mounted resize grip.
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
height: 0.1rem;
|
||||
:global(.resizable-box__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
:global(.quick-filters-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
@@ -142,7 +136,9 @@
|
||||
}
|
||||
|
||||
.listContainerFiltersVisible {
|
||||
max-width: calc(100% - 280px);
|
||||
// Width is driven by flex now that the filters panel is resizable; the list
|
||||
// fills whatever space the (variable-width) panel leaves.
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.quickFiltersToggleContainer {
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
ChevronsLeftRight,
|
||||
Compass,
|
||||
DraftingCompass,
|
||||
Package2,
|
||||
ScrollText,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
@@ -54,12 +53,10 @@ import { openInNewTab } from 'utils/navigation';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { InfraMonitoringEntity, VIEW_TYPES } from '../constants';
|
||||
import EntityContainers from '../EntityDetailsUtils/EntityContainers';
|
||||
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 EntityProcesses from '../EntityDetailsUtils/EntityProcesses';
|
||||
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
|
||||
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
|
||||
import {
|
||||
@@ -120,8 +117,6 @@ export interface K8sBaseDetailsProps<T> {
|
||||
showLogs?: boolean;
|
||||
showTraces?: boolean;
|
||||
showEvents?: boolean;
|
||||
showContainers?: boolean;
|
||||
showProcesses?: boolean;
|
||||
};
|
||||
customTabs?: Array<{
|
||||
key: string;
|
||||
@@ -259,8 +254,6 @@ export default function K8sBaseDetails<T>({
|
||||
showLogs: true,
|
||||
showTraces: true,
|
||||
showEvents: true,
|
||||
showContainers: false,
|
||||
showProcesses: false,
|
||||
...tabsConfig,
|
||||
}),
|
||||
[tabsConfig],
|
||||
@@ -575,32 +568,6 @@ export default function K8sBaseDetails<T>({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showContainers
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.CONTAINERS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<Package2 size={14} />
|
||||
Containers
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showProcesses
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.PROCESSES,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<ChevronsLeftRight size={14} />
|
||||
Processes
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(customTabs?.map((tab) => ({
|
||||
value: tab.key,
|
||||
label: (
|
||||
@@ -680,11 +647,6 @@ export default function K8sBaseDetails<T>({
|
||||
initialExpression={eventsInitialExpression}
|
||||
/>
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.CONTAINERS &&
|
||||
tabVisibility.showContainers && <EntityContainers />}
|
||||
{effectiveView === VIEW_TYPES.PROCESSES && tabVisibility.showProcesses && (
|
||||
<EntityProcesses />
|
||||
)}
|
||||
{customTabs?.map((tab) =>
|
||||
selectedView === tab.key ? (
|
||||
<React.Fragment key={tab.key}>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Space } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import WaitlistFragment from 'components/HostMetricsDetail/WaitlistFragment/WaitlistFragment';
|
||||
|
||||
import broomUrl from '@/assets/Icons/broom.svg';
|
||||
import infraContainersUrl from '@/assets/Icons/infraContainers.svg';
|
||||
|
||||
import 'components/HostMetricsDetail/Containers/Containers.styles.scss';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function EntityContainers(): JSX.Element {
|
||||
const { t } = useTranslation(['infraMonitoring']);
|
||||
|
||||
return (
|
||||
<Space direction="vertical" className="host-containers" size={24}>
|
||||
<div className="infra-container-card-container">
|
||||
<div className="dev-status-container">
|
||||
<div className="infra-container-card">
|
||||
<img
|
||||
src={infraContainersUrl}
|
||||
alt="infra-container"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
|
||||
<Text className="infra-container-card-text">
|
||||
{t('containers_visualization_message')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="infra-container-working-msg">
|
||||
<Space>
|
||||
<img src={broomUrl} alt="broom" width={24} height={24} />
|
||||
<Text className="infra-container-card-text">{t('working_message')}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WaitlistFragment entityType="containers" />
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export default EntityContainers;
|
||||
@@ -1,46 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Space } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import WaitlistFragment from 'components/HostMetricsDetail/WaitlistFragment/WaitlistFragment';
|
||||
|
||||
import broomUrl from '@/assets/Icons/broom.svg';
|
||||
import infraContainersUrl from '@/assets/Icons/infraContainers.svg';
|
||||
|
||||
import 'components/HostMetricsDetail/Processes/Processes.styles.scss';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function EntityProcesses(): JSX.Element {
|
||||
const { t } = useTranslation(['infraMonitoring']);
|
||||
|
||||
return (
|
||||
<Space direction="vertical" className="host-processes" size={24}>
|
||||
<div className="infra-container-card-container">
|
||||
<div className="dev-status-container">
|
||||
<div className="infra-container-card">
|
||||
<img
|
||||
src={infraContainersUrl}
|
||||
alt="infra-container"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<Text className="infra-container-card-text">
|
||||
{t('processes_visualization_message')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="infra-container-working-msg">
|
||||
<Space>
|
||||
<img src={broomUrl} alt="broom" width={24} height={24} />
|
||||
<Text className="infra-container-card-text">{t('working_message')}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WaitlistFragment entityType="processes" />
|
||||
</div>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export default EntityProcesses;
|
||||
@@ -0,0 +1,98 @@
|
||||
import { act, waitFor } from 'tests/test-utils';
|
||||
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import { renderEntityTraces, verifyQueryPayload } from './testUtils';
|
||||
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
|
||||
|
||||
describe('EntityTraces - Default Behavior', () => {
|
||||
let capturedPayloads: QueryRangePayloadV5[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
capturedPayloads = [];
|
||||
mockQueryRangeV5WithTracesResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const body = (await req.json()) as QueryRangePayloadV5;
|
||||
capturedPayloads.push(body);
|
||||
return {};
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch traces using V5 API on initial render', async () => {
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPayloads).toHaveLength(1);
|
||||
});
|
||||
|
||||
verifyQueryPayload({
|
||||
payload: capturedPayloads[0],
|
||||
expectedOffset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass time range to API (converted to milliseconds)', async () => {
|
||||
const timeRange = { startTime: 1000, endTime: 2000 };
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces({ timeRange });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPayloads).toHaveLength(1);
|
||||
});
|
||||
|
||||
verifyQueryPayload({
|
||||
payload: capturedPayloads[0],
|
||||
expectedTimeRange: {
|
||||
start: timeRange.startTime * 1000,
|
||||
end: timeRange.endTime * 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should order results by timestamp desc', async () => {
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPayloads).toHaveLength(1);
|
||||
});
|
||||
|
||||
const spec = capturedPayloads[0].compositeQuery.queries[0]?.spec as {
|
||||
order?: Array<{ key: { name: string }; direction: string }>;
|
||||
};
|
||||
const timestampOrder = spec.order?.find((o) => o.key.name === 'timestamp');
|
||||
|
||||
expect(timestampOrder?.direction).toBe('desc');
|
||||
});
|
||||
|
||||
it('should use TRACES signal type', async () => {
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPayloads).toHaveLength(1);
|
||||
});
|
||||
|
||||
const query = capturedPayloads[0].compositeQuery.queries[0];
|
||||
const spec = query?.spec as { signal?: string };
|
||||
expect(spec?.signal).toBe('traces');
|
||||
});
|
||||
|
||||
it('should use raw request type for traces list view', async () => {
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPayloads).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(capturedPayloads[0].requestType).toBe('raw');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { act, screen } from 'tests/test-utils';
|
||||
|
||||
import { renderEntityTraces } from './testUtils';
|
||||
import { mockQueryRangeV5WithEmptyTraces } from '__tests__/query_range_v5.util';
|
||||
|
||||
describe('EntityTraces - Empty State', () => {
|
||||
it('should show empty state when no traces returned', async () => {
|
||||
mockQueryRangeV5WithEmptyTraces();
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show table when traces are empty', async () => {
|
||||
mockQueryRangeV5WithEmptyTraces();
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show pagination controls when traces are empty', async () => {
|
||||
mockQueryRangeV5WithEmptyTraces();
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByText(/1 - 10/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { act, screen } from 'tests/test-utils';
|
||||
|
||||
import { renderEntityTraces } from './testUtils';
|
||||
import {
|
||||
mockQueryRangeV5WithError,
|
||||
mockQueryRangeV5WithKeyNotFoundError,
|
||||
} from '__tests__/query_range_v5.util';
|
||||
|
||||
describe('EntityTraces - Error State', () => {
|
||||
it('should show error state on API error', async () => {
|
||||
mockQueryRangeV5WithError('Internal server error', 500);
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText(/Something went wrong/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty state for key not found error', async () => {
|
||||
mockQueryRangeV5WithKeyNotFoundError();
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await expect(screen.findByText(/No data yet/i)).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show table on API error', async () => {
|
||||
mockQueryRangeV5WithError('Internal server error', 500);
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText(/Something went wrong/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByRole('table')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { act, waitFor } from 'tests/test-utils';
|
||||
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import { renderEntityTraces } from './testUtils';
|
||||
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
|
||||
|
||||
describe('EntityTraces - Expression', () => {
|
||||
let capturedPayloads: QueryRangePayloadV5[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
capturedPayloads = [];
|
||||
mockQueryRangeV5WithTracesResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const body = (await req.json()) as QueryRangePayloadV5;
|
||||
capturedPayloads.push(body);
|
||||
return {};
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should include expression in query filter', async () => {
|
||||
const expression = 'k8s.pod.name = "my-pod"';
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces({ expression });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPayloads).toHaveLength(1);
|
||||
});
|
||||
|
||||
const spec = capturedPayloads[0].compositeQuery.queries[0]?.spec as {
|
||||
filter?: { expression: string };
|
||||
};
|
||||
expect(spec.filter?.expression).toContain('k8s.pod.name');
|
||||
});
|
||||
|
||||
it('should include complex expression with multiple conditions', async () => {
|
||||
const expression = 'k8s.pod.name = "my-pod" AND service.name = "api"';
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces({ expression });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPayloads).toHaveLength(1);
|
||||
});
|
||||
|
||||
const spec = capturedPayloads[0].compositeQuery.queries[0]?.spec as {
|
||||
filter?: { expression: string };
|
||||
};
|
||||
expect(spec.filter?.expression).toContain('k8s.pod.name');
|
||||
expect(spec.filter?.expression).toContain('service.name');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { act, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import { renderEntityTraces } from './testUtils';
|
||||
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
|
||||
|
||||
describe('EntityTraces - Loading State', () => {
|
||||
it('should show loading state while fetching', async () => {
|
||||
mockQueryRangeV5WithTracesResponse({ delay: 1000 });
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText(/pending_data_placeholder/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide loading state after data loads', async () => {
|
||||
mockQueryRangeV5WithTracesResponse({ delay: 100 });
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText(/pending_data_placeholder/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(
|
||||
screen.queryByText(/pending_data_placeholder/i),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { act, waitFor } from 'tests/test-utils';
|
||||
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import { renderEntityTraces, verifyQueryPayload } from './testUtils';
|
||||
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
|
||||
|
||||
describe('EntityTraces - Pagination', () => {
|
||||
let capturedPayloads: QueryRangePayloadV5[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
capturedPayloads = [];
|
||||
mockQueryRangeV5WithTracesResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const body = (await req.json()) as QueryRangePayloadV5;
|
||||
capturedPayloads.push(body);
|
||||
return {};
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default limit when no pagination provided', async () => {
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPayloads).toHaveLength(1);
|
||||
});
|
||||
|
||||
const spec = capturedPayloads[0].compositeQuery.queries[0]?.spec as {
|
||||
limit?: number;
|
||||
};
|
||||
expect(spec.limit).toBe(10);
|
||||
});
|
||||
|
||||
it('should use custom offset from pagination param', async () => {
|
||||
act(() => {
|
||||
renderEntityTraces({ pagination: { offset: 20, limit: 10 } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPayloads).toHaveLength(1);
|
||||
});
|
||||
|
||||
verifyQueryPayload({
|
||||
payload: capturedPayloads[0],
|
||||
expectedOffset: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom limit from pagination param', async () => {
|
||||
act(() => {
|
||||
renderEntityTraces({ pagination: { offset: 0, limit: 50 } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPayloads).toHaveLength(1);
|
||||
});
|
||||
|
||||
verifyQueryPayload({
|
||||
payload: capturedPayloads[0],
|
||||
expectedLimit: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('should default offset to 0', async () => {
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPayloads).toHaveLength(1);
|
||||
});
|
||||
|
||||
verifyQueryPayload({
|
||||
payload: capturedPayloads[0],
|
||||
expectedOffset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should combine offset and limit correctly', async () => {
|
||||
act(() => {
|
||||
renderEntityTraces({ pagination: { offset: 30, limit: 20 } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedPayloads).toHaveLength(1);
|
||||
});
|
||||
|
||||
verifyQueryPayload({
|
||||
payload: capturedPayloads[0],
|
||||
expectedOffset: 30,
|
||||
expectedLimit: 20,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { act, screen, within } from 'tests/test-utils';
|
||||
|
||||
import { renderEntityTraces } from './testUtils';
|
||||
import { mockQueryRangeV5WithTracesResponse } from '__tests__/query_range_v5.util';
|
||||
|
||||
// Trace list columns are hidden below the antd `md` breakpoint. The global
|
||||
// matchMedia mock reports `matches: false`, which drops every column, so make
|
||||
// all breakpoints match for these rendering tests.
|
||||
jest.spyOn(window, 'matchMedia').mockImplementation(
|
||||
(query: string) =>
|
||||
({
|
||||
matches: true,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}) as unknown as MediaQueryList,
|
||||
);
|
||||
|
||||
describe('EntityTraces - Table Rendering', () => {
|
||||
it('should render table with all column headers', async () => {
|
||||
mockQueryRangeV5WithTracesResponse({ pageSize: 3 });
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await expect(screen.findByText('Service Name')).resolves.toBeInTheDocument();
|
||||
|
||||
const headerTexts = within(screen.getByRole('table'))
|
||||
.getAllByRole('columnheader')
|
||||
.map((header) => header.textContent);
|
||||
|
||||
expect(headerTexts).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
'Timestamp',
|
||||
'Service Name',
|
||||
'Name',
|
||||
'Duration',
|
||||
'HTTP Method',
|
||||
'Status Code',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should render trace values in table cells', async () => {
|
||||
mockQueryRangeV5WithTracesResponse({
|
||||
customTraces: [
|
||||
{
|
||||
serviceName: 'checkout-service',
|
||||
name: 'GET /api/checkout',
|
||||
durationNano: 5000000,
|
||||
httpMethod: 'GET',
|
||||
responseStatusCode: '200',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await expect(screen.findByTestId('serviceName')).resolves.toHaveTextContent(
|
||||
'checkout-service',
|
||||
);
|
||||
expect(screen.getByTestId('name')).toHaveTextContent('GET /api/checkout');
|
||||
expect(screen.getByTestId('durationNano')).toHaveTextContent('5.00ms');
|
||||
expect(screen.getByTestId('httpMethod')).toHaveTextContent('GET');
|
||||
expect(screen.getByTestId('responseStatusCode')).toHaveTextContent('200');
|
||||
});
|
||||
|
||||
it('should render http method as robin colored outline badge', async () => {
|
||||
mockQueryRangeV5WithTracesResponse({
|
||||
customTraces: [{ httpMethod: 'POST', responseStatusCode: '200' }],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
const badge = await screen.findByTestId('httpMethod');
|
||||
expect(badge).toHaveTextContent('POST');
|
||||
expect(badge).toHaveAttribute('data-color', 'robin');
|
||||
expect(badge).toHaveAttribute('data-variant', 'outline');
|
||||
});
|
||||
|
||||
it('should render N/A when http method is empty', async () => {
|
||||
mockQueryRangeV5WithTracesResponse({
|
||||
customTraces: [{ httpMethod: '', responseStatusCode: '200' }],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await expect(screen.findByText('N/A')).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('httpMethod')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render status code badge color per status range', async () => {
|
||||
mockQueryRangeV5WithTracesResponse({
|
||||
customTraces: [
|
||||
{ responseStatusCode: '200' },
|
||||
{ responseStatusCode: '302' },
|
||||
{ responseStatusCode: '404' },
|
||||
{ responseStatusCode: '500' },
|
||||
{ responseStatusCode: '100' },
|
||||
],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
const badges = await screen.findAllByTestId('responseStatusCode');
|
||||
const badgeColors = badges.map((badge) => badge.getAttribute('data-color'));
|
||||
|
||||
expect(badgeColors).toStrictEqual([
|
||||
'forest', // 2xx -> success
|
||||
'robin', // 3xx -> redirect
|
||||
'amber', // 4xx -> client error
|
||||
'cherry', // 5xx -> server error
|
||||
'vanilla', // 1xx -> informational
|
||||
]);
|
||||
});
|
||||
|
||||
it('should render non-numeric status code as plain text without badge', async () => {
|
||||
mockQueryRangeV5WithTracesResponse({
|
||||
customTraces: [{ responseStatusCode: 'unknown' }],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await expect(screen.findByText('unknown')).resolves.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('responseStatusCode')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should link cells to trace details page', async () => {
|
||||
mockQueryRangeV5WithTracesResponse({
|
||||
customTraces: [{ serviceName: 'frontend' }],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
await expect(screen.findByTestId('serviceName')).resolves.toBeInTheDocument();
|
||||
|
||||
const links = within(screen.getByRole('table')).getAllByRole('link');
|
||||
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
links.forEach((link) => {
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
expect.stringContaining('/trace/trace-id-0'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should render one row per trace', async () => {
|
||||
mockQueryRangeV5WithTracesResponse({
|
||||
customTraces: [
|
||||
{ serviceName: 'frontend' },
|
||||
{ serviceName: 'backend' },
|
||||
{ serviceName: 'database' },
|
||||
],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
renderEntityTraces();
|
||||
});
|
||||
|
||||
const serviceCells = await screen.findAllByTestId('serviceName');
|
||||
expect(serviceCells.map((cell) => cell.textContent)).toStrictEqual([
|
||||
'frontend',
|
||||
'backend',
|
||||
'database',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
import { mockQueryRangeV5WithLogsResponse } from '__tests__/query_range_v5.util';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { act, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import EntityTraces from '../EntityTraces';
|
||||
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../hooks';
|
||||
|
||||
function verifyEntityTracesV5Request({
|
||||
payload,
|
||||
expectedOffset,
|
||||
initialTimeRange,
|
||||
}: {
|
||||
payload: QueryRangePayloadV5;
|
||||
expectedOffset: number;
|
||||
initialTimeRange?: { start: number; end: number };
|
||||
}): void {
|
||||
const spec = payload.compositeQuery.queries[0]?.spec as {
|
||||
offset?: number;
|
||||
order?: Array<{ key: { name: string }; direction: string }>;
|
||||
};
|
||||
expect(spec.offset).toBe(expectedOffset);
|
||||
if (initialTimeRange) {
|
||||
expect(payload.start).toBe(initialTimeRange.start);
|
||||
expect(payload.end).toBe(initialTimeRange.end);
|
||||
}
|
||||
const orderKeys = spec.order?.map((o) => o.key.name) ?? [];
|
||||
expect(orderKeys).toContain('timestamp');
|
||||
}
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
onTimeChange,
|
||||
}: {
|
||||
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
|
||||
}): JSX.Element => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mock-datetime-selection"
|
||||
onClick={(): void => {
|
||||
onTimeChange?.('5m');
|
||||
}}
|
||||
>
|
||||
Select Time
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('EntityTraces', () => {
|
||||
let capturedQueryRangePayloads: QueryRangePayloadV5[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
capturedQueryRangePayloads = [];
|
||||
mockQueryRangeV5WithLogsResponse({
|
||||
onReceiveRequest: async (req) => {
|
||||
const body = (await req.json()) as QueryRangePayloadV5;
|
||||
capturedQueryRangePayloads.push(body);
|
||||
return {};
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should use V5 API for fetching traces', async () => {
|
||||
act(() => {
|
||||
render(
|
||||
<NuqsTestingAdapter
|
||||
searchParams={`${K8S_ENTITY_TRACES_EXPRESSION_KEY}=k8s.pod.name+%3D+%22x%22`}
|
||||
>
|
||||
<EntityTraces
|
||||
timeRange={{ startTime: 1, endTime: 2 }}
|
||||
isModalTimeSelection={false}
|
||||
handleTimeChange={jest.fn()}
|
||||
selectedInterval="5m"
|
||||
queryKey="test"
|
||||
category={InfraMonitoringEntity.PODS}
|
||||
initialExpression='k8s.pod.name = "x"'
|
||||
/>
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText('pending_data_placeholder'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedQueryRangePayloads).toHaveLength(1);
|
||||
});
|
||||
|
||||
const firstPayload = capturedQueryRangePayloads[0];
|
||||
verifyEntityTracesV5Request({
|
||||
payload: firstPayload,
|
||||
expectedOffset: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, RenderResult } from 'tests/test-utils';
|
||||
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
|
||||
|
||||
import EntityTraces from '../EntityTraces';
|
||||
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../hooks';
|
||||
|
||||
// QuerySearch fires autocomplete requests on mount; without handlers MSW
|
||||
// passes them through to the real network and the resulting AxiosError fails
|
||||
// whichever test happens to be running.
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/keys`, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { keys: {}, complete: true } }),
|
||||
),
|
||||
),
|
||||
rest.get(`${ENVIRONMENT.baseURL}/api/v1/fields/values`, (_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: { values: {}, complete: true } }),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
export interface RenderEntityTracesOptions {
|
||||
expression?: string;
|
||||
timeRange?: { startTime: number; endTime: number };
|
||||
category?: InfraMonitoringEntity;
|
||||
selectedInterval?: '5m' | '15m' | '30m' | '1h';
|
||||
pagination?: { offset: number; limit: number };
|
||||
}
|
||||
|
||||
export function renderEntityTraces({
|
||||
expression = 'k8s.pod.name = "test-pod"',
|
||||
timeRange = { startTime: 1, endTime: 2 },
|
||||
category = InfraMonitoringEntity.PODS,
|
||||
selectedInterval = '5m',
|
||||
pagination,
|
||||
}: RenderEntityTracesOptions = {}): RenderResult {
|
||||
const encodedExpression = encodeURIComponent(expression);
|
||||
let searchParams = `${K8S_ENTITY_TRACES_EXPRESSION_KEY}=${encodedExpression}`;
|
||||
|
||||
if (pagination) {
|
||||
const paginationStr = encodeURIComponent(JSON.stringify(pagination));
|
||||
searchParams += `&pagination=${paginationStr}`;
|
||||
}
|
||||
|
||||
return render(
|
||||
<NuqsTestingAdapter searchParams={searchParams}>
|
||||
<EntityTraces
|
||||
timeRange={timeRange}
|
||||
isModalTimeSelection={false}
|
||||
handleTimeChange={jest.fn()}
|
||||
selectedInterval={selectedInterval}
|
||||
queryKey="test"
|
||||
category={category}
|
||||
initialExpression={expression}
|
||||
/>
|
||||
</NuqsTestingAdapter>,
|
||||
);
|
||||
}
|
||||
|
||||
export function verifyQueryPayload({
|
||||
payload,
|
||||
expectedOffset,
|
||||
expectedLimit,
|
||||
expectedTimeRange,
|
||||
}: {
|
||||
payload: QueryRangePayloadV5;
|
||||
expectedOffset?: number;
|
||||
expectedLimit?: number;
|
||||
expectedTimeRange?: { start: number; end: number };
|
||||
}): void {
|
||||
const spec = payload.compositeQuery.queries[0]?.spec as {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
order?: Array<{ key: { name: string }; direction: string }>;
|
||||
};
|
||||
|
||||
if (expectedOffset !== undefined) {
|
||||
expect(spec.offset).toBe(expectedOffset);
|
||||
}
|
||||
|
||||
if (expectedLimit !== undefined) {
|
||||
expect(spec.limit).toBe(expectedLimit);
|
||||
}
|
||||
|
||||
if (expectedTimeRange) {
|
||||
expect(payload.start).toBe(expectedTimeRange.start);
|
||||
expect(payload.end).toBe(expectedTimeRange.end);
|
||||
}
|
||||
|
||||
const orderKeys = spec.order?.map((o) => o.key.name) ?? [];
|
||||
expect(orderKeys).toContain('timestamp');
|
||||
}
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
onTimeChange,
|
||||
}: {
|
||||
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
|
||||
}): JSX.Element => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="mock-datetime-selection"
|
||||
onClick={(): void => {
|
||||
onTimeChange?.('5m');
|
||||
}}
|
||||
>
|
||||
Select Time
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
@@ -0,0 +1,7 @@
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cellText {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import {
|
||||
BlockLink,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { FormatTimezoneAdjustedTimestamp } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
import styles from './traceListColumns.module.scss';
|
||||
|
||||
const keyToLabelMap: Record<string, string> = {
|
||||
timestamp: 'Timestamp',
|
||||
@@ -48,8 +50,9 @@ const getValueForKey = (data: Record<string, any>, key: string): any => {
|
||||
const aliases = keyAliases[primaryKey];
|
||||
if (aliases) {
|
||||
for (const alias of aliases) {
|
||||
if (data[alias] !== undefined) {
|
||||
return data[alias];
|
||||
const aliasedValue = data[alias] || data.resource?.[alias];
|
||||
if (aliasedValue !== undefined) {
|
||||
return aliasedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,7 +81,7 @@ export const getTraceListColumns = (
|
||||
|
||||
return (
|
||||
<BlockLink to={getTraceLink(itemData)} openInNewTab>
|
||||
<Typography.Text>{date}</Typography.Text>
|
||||
<Typography.Text className={styles.cellText}>{date}</Typography.Text>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
@@ -86,34 +89,81 @@ export const getTraceListColumns = (
|
||||
if (value === '') {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(itemData)} openInNewTab>
|
||||
<Typography data-testid={key}>N/A</Typography>
|
||||
<Typography data-testid={key} className={styles.cellText}>
|
||||
N/A
|
||||
</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (primaryKey === 'httpMethod' || primaryKey === 'responseStatusCode') {
|
||||
if (primaryKey === 'httpMethod') {
|
||||
const httpMethod = getValueForKey(itemData, key);
|
||||
|
||||
if (!httpMethod) {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(itemData)} openInNewTab>
|
||||
<Typography className={styles.cellText}>N/A</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BlockLink to={getTraceLink(itemData)} openInNewTab>
|
||||
<Badge data-testid={key} color="sakura">
|
||||
{getValueForKey(itemData, key)}
|
||||
<Badge
|
||||
data-testid={key}
|
||||
color="robin"
|
||||
variant="outline"
|
||||
className={styles.pointer}
|
||||
>
|
||||
{httpMethod}
|
||||
</Badge>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (primaryKey === 'responseStatusCode') {
|
||||
const statusCode = getValueForKey(itemData, key);
|
||||
const numericCode = Number(statusCode);
|
||||
const isValidCode = !Number.isNaN(numericCode) && numericCode > 0;
|
||||
|
||||
if (!isValidCode) {
|
||||
return (
|
||||
<BlockLink to={getTraceLink(itemData)} openInNewTab>
|
||||
<Typography className={styles.cellText}>
|
||||
{numericCode === 0 || !statusCode ? 'N/A' : statusCode}
|
||||
</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BlockLink to={getTraceLink(itemData)} openInNewTab>
|
||||
<HttpStatusBadge
|
||||
statusCode={statusCode}
|
||||
testId={key}
|
||||
className={styles.pointer}
|
||||
/>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (primaryKey === 'durationNano') {
|
||||
const durationNano = getValueForKey(itemData, key);
|
||||
|
||||
return (
|
||||
<BlockLink to={getTraceLink(itemData)} openInNewTab>
|
||||
<Typography data-testid={key}>{getMs(durationNano)}ms</Typography>
|
||||
<Typography data-testid={key} className={styles.cellText}>
|
||||
{getMs(durationNano)}ms
|
||||
</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BlockLink to={getTraceLink(itemData)} openInNewTab>
|
||||
<Typography data-testid={key}>{getValueForKey(itemData, key)}</Typography>
|
||||
<Typography data-testid={key} className={styles.cellText}>
|
||||
{getValueForKey(itemData, key)}
|
||||
</Typography>
|
||||
</BlockLink>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -44,26 +44,15 @@
|
||||
}
|
||||
|
||||
.quickFiltersContainer {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
// Width is owned by ResizableBox (inline style). No overflow here: the panel
|
||||
// must not scroll (QuickFilters scrolls its own list internally) — an
|
||||
// overflow on the root would also clip the edge-mounted resize grip.
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
height: 0.1rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
:global(.resizable-box__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:global(.quick-filters) {
|
||||
@@ -128,7 +117,9 @@
|
||||
}
|
||||
|
||||
.listContainerFiltersVisible {
|
||||
max-width: calc(100% - 280px);
|
||||
// Width is driven by flex now that the filters panel is resizable; the list
|
||||
// fills whatever space the (variable-width) panel leaves.
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.categorySelectorSection {
|
||||
@@ -219,8 +210,16 @@
|
||||
|
||||
.quickFiltersSection {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.quick-filters-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import {
|
||||
@@ -22,6 +23,8 @@ import {
|
||||
Workflow,
|
||||
} from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { FeatureKeys } from '../../constants/features';
|
||||
@@ -56,9 +59,23 @@ import K8sVolumesList from './Volumes/K8sVolumesList';
|
||||
|
||||
import styles from './InfraMonitoringK8s.module.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
export default function InfraMonitoringK8s(): JSX.Element {
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_INFRA,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useInfraMonitoringCategory();
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
const [, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
@@ -212,7 +229,18 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className={styles.quickFiltersContainer}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<div className={styles.categorySelectorSection}>
|
||||
<div className={styles.sectionHeader} data-type="resource">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
@@ -265,7 +293,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizableBox>
|
||||
)}
|
||||
|
||||
<div
|
||||
|
||||
@@ -34,8 +34,6 @@ export const VIEW_TYPES = {
|
||||
METRICS: VIEWS.METRICS,
|
||||
LOGS: VIEWS.LOGS,
|
||||
TRACES: VIEWS.TRACES,
|
||||
CONTAINERS: VIEWS.CONTAINERS,
|
||||
PROCESSES: VIEWS.PROCESSES,
|
||||
EVENTS: VIEWS.EVENTS,
|
||||
};
|
||||
|
||||
@@ -394,41 +392,6 @@ export function GetClustersQuickFiltersConfig(
|
||||
];
|
||||
}
|
||||
|
||||
export function GetContainersQuickFiltersConfig(
|
||||
dotMetricsEnabled: boolean,
|
||||
): IQuickFiltersConfig[] {
|
||||
const containerKey = dotMetricsEnabled
|
||||
? 'k8s.container.name'
|
||||
: 'k8s_container_name';
|
||||
const environmentKey = dotMetricsEnabled
|
||||
? 'deployment.environment'
|
||||
: 'deployment_environment';
|
||||
|
||||
return [
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Container',
|
||||
attributeKey: {
|
||||
key: containerKey,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
id: `${containerKey}--string--resource`,
|
||||
},
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Environment',
|
||||
attributeKey: {
|
||||
key: environmentKey,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
defaultOpen: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function GetVolumesQuickFiltersConfig(
|
||||
dotMetricsEnabled: boolean,
|
||||
): IQuickFiltersConfig[] {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
.llmObservabilityAttributeMapping {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
}
|
||||
|
||||
.tableEmpty {
|
||||
padding: var(--spacing-12) var(--spacing-6);
|
||||
text-align: center;
|
||||
color: var(--l3-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import AttributeMappingHeader from './components/AttributeMappingHeader';
|
||||
import styles from './LLMObservabilityAttributeMapping.module.scss';
|
||||
|
||||
const noop = (): void => undefined;
|
||||
|
||||
function LLMObservabilityAttributeMapping(): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.llmObservabilityAttributeMapping}
|
||||
data-testid="llm-observability-attribute-mapping-page"
|
||||
>
|
||||
<AttributeMappingHeader
|
||||
isDirty={false}
|
||||
isSaving={false}
|
||||
onDiscard={noop}
|
||||
onSave={noop}
|
||||
/>
|
||||
|
||||
<div className={styles.tableEmpty} data-testid="attribute-mapping-empty">
|
||||
No mapping groups configured yet.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LLMObservabilityAttributeMapping;
|
||||
@@ -0,0 +1,34 @@
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-large);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
.pageHeaderActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.unsavedChanges {
|
||||
font-size: var(--periscope-font-size-base);
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import styles from './AttributeMappingHeader.module.scss';
|
||||
|
||||
interface AttributeMappingHeaderProps {
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
onDiscard: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
function AttributeMappingHeader({
|
||||
isDirty,
|
||||
isSaving,
|
||||
onDiscard,
|
||||
onSave,
|
||||
}: AttributeMappingHeaderProps): JSX.Element {
|
||||
return (
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<h1 className={styles.title}>Attribute Mapping</h1>
|
||||
<p className={styles.description}>
|
||||
Configure source-to-target attribute remapping for LLM traces
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.pageHeaderActions}>
|
||||
{isDirty && (
|
||||
<span className={styles.unsavedChanges} data-testid="unsaved-changes">
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={onDiscard}
|
||||
disabled={!isDirty || isSaving}
|
||||
testId="discard-changes-btn"
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSave}
|
||||
loading={isSaving}
|
||||
disabled={!isDirty || isSaving}
|
||||
testId="save-changes-btn"
|
||||
>
|
||||
{isSaving ? 'Saving…' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeMappingHeader;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AttributeMappingHeader';
|
||||
@@ -1,27 +1,7 @@
|
||||
.llmObservability {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
height: 100%;
|
||||
margin-top: var(--spacing-2);
|
||||
margin-left: var(--spacing-2);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
|
||||
import { useLLMObservabilityTabs } from './hooks/useLLMObservabilityTabs';
|
||||
import styles from './LLMObservability.module.scss';
|
||||
|
||||
// Shell for the LLM Observability page: renders the top-level tab bar
|
||||
// (Overview / Configuration) using the SigNoz design-system Tabs, with
|
||||
// route-driven active state from useLLMObservabilityTabs.
|
||||
function LLMObservability(): JSX.Element {
|
||||
const { items, activeTab, onTabChange } = useLLMObservabilityTabs();
|
||||
|
||||
return (
|
||||
<div className={styles.llmObservability} data-testid="llm-observability-page">
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<h1 className={styles.title}>LLM Observability</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Monitor and analyze your LLM usage, costs, and performance
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<Tabs
|
||||
items={items}
|
||||
value={activeTab}
|
||||
onChange={onTabChange}
|
||||
testId="llm-observability-tabs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
.overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import styles from './Overview.module.scss';
|
||||
|
||||
// Overview tab content for LLM Observability. Currently the feature's landing
|
||||
// surface; usage/cost/performance widgets land in later PRs.
|
||||
function Overview(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.overview} data-testid="llm-observability-overview">
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<h1 className={styles.title}>LLM Observability</h1>
|
||||
<p className={styles.subtitle}>
|
||||
Monitor and analyze your LLM usage, costs, and performance
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Overview;
|
||||
@@ -2,29 +2,4 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
}
|
||||
|
||||
.pageHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.pageHeaderTitle {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: var(--spacing-2) 0 0;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import ModelCostTabPanel from './ModelCostTabPanel';
|
||||
import styles from './LLMObservabilityModelPricing.module.scss';
|
||||
@@ -10,20 +9,9 @@ function LLMObservabilityModelPricing(): JSX.Element {
|
||||
className={styles.llmObservabilityModelPricing}
|
||||
data-testid="llm-observability-model-pricing-page"
|
||||
>
|
||||
<header className={styles.pageHeader}>
|
||||
<div className={styles.pageHeaderTitle}>
|
||||
<Typography.Text as="h1" size="large" weight="semibold">
|
||||
Configuration
|
||||
</Typography.Text>
|
||||
<Typography.Text color="muted">
|
||||
Model pricing and cost estimation settings
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
// Model costs is the only enabled tab for now, so default to it. When
|
||||
// the unpriced-models tab lands, this can become a URL-backed param.
|
||||
// the unpriced-models tab lands in a later PR.
|
||||
defaultValue="model-costs"
|
||||
items={[
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Plus, Search, X } from '@signozhq/icons';
|
||||
import { useListLLMPricingRules } from 'api/generated/services/llmpricingrules';
|
||||
import { type ListLLMPricingRulesParams } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -161,12 +160,6 @@ function ModelCostTabPanel(): JSX.Element {
|
||||
onDelete={deletion.requestDelete}
|
||||
/>
|
||||
|
||||
<footer>
|
||||
<Typography.Text color="muted" size="small">
|
||||
All prices per 1M tokens (USD)
|
||||
</Typography.Text>
|
||||
</footer>
|
||||
|
||||
{drawer.isOpen && (
|
||||
<ModelCostDrawer
|
||||
isOpen={drawer.isOpen}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
TOAST_MODEL_COST_DELETED,
|
||||
TOAST_MODEL_COST_UPDATED,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/constants';
|
||||
import {
|
||||
LLM_PRICING_ENDPOINT,
|
||||
LLM_PRICING_RULE_ENDPOINT,
|
||||
makeListResponse,
|
||||
mockRules,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import ModelCostTabPanel from '../ModelCostTabPanel';
|
||||
|
||||
const toastSuccess = jest.fn();
|
||||
const toastError = jest.fn();
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: (...args: unknown[]): void => toastSuccess(...args),
|
||||
error: (...args: unknown[]): void => toastError(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
function setupList(items = mockRules, total = items.length): void {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeListResponse(items, total))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function resetUrl(): void {
|
||||
window.history.pushState(null, '', '/');
|
||||
}
|
||||
|
||||
async function openRowMenu(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
ruleId: string,
|
||||
): Promise<void> {
|
||||
const row = screen.getByTestId(`model-cell-name-${ruleId}`).closest('tr');
|
||||
await user.click(within(row as HTMLElement).getByRole('button'));
|
||||
}
|
||||
|
||||
describe('ModelCostTabPanel (integration)', () => {
|
||||
beforeEach(() => {
|
||||
resetUrl();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders pricing rules returned by the list API', async () => {
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const openaiCell = await screen.findByTestId('model-cell-name-rule-openai');
|
||||
expect(openaiCell).toHaveTextContent('gpt-4o');
|
||||
expect(
|
||||
screen.getByTestId('model-cell-name-rule-anthropic'),
|
||||
).toHaveTextContent('claude-3-5-sonnet');
|
||||
// Canonical id under the model name + provider column.
|
||||
expect(screen.getByText('openai:gpt-4o')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('OpenAI').length).toBeGreaterThan(0);
|
||||
// Source badges reflect the override flag.
|
||||
expect(screen.getByTestId('source-badge-rule-openai')).toHaveTextContent(
|
||||
'User override',
|
||||
);
|
||||
expect(screen.getByTestId('source-badge-rule-anthropic')).toHaveTextContent(
|
||||
'Auto',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the empty state when there are no rules', async () => {
|
||||
setupList([], 0);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const empty = await screen.findByTestId('model-costs-empty');
|
||||
expect(empty).toHaveTextContent('No model costs yet.');
|
||||
});
|
||||
|
||||
it('shows an error message when the list request fails', async () => {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) => res(ctx.status(500))),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert).toHaveTextContent(
|
||||
'Failed to load pricing rules. Please try again.',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the debounced search term as the q param', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let lastParams: URLSearchParams | null = null;
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (req, res, ctx) => {
|
||||
lastParams = req.url.searchParams;
|
||||
return res(ctx.status(200), ctx.json(makeListResponse(mockRules)));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('Search by model or provider'),
|
||||
'claude',
|
||||
);
|
||||
|
||||
await waitFor(() => expect(lastParams?.get('q')).toBe('claude'));
|
||||
});
|
||||
|
||||
it('clears the search via the clear button', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
'Search by model or provider',
|
||||
) as HTMLInputElement;
|
||||
await user.type(input, 'gpt');
|
||||
expect(input.value).toBe('gpt');
|
||||
|
||||
await user.click(screen.getByTestId('model-cost-search-clear'));
|
||||
await waitFor(() => expect(input.value).toBe(''));
|
||||
});
|
||||
|
||||
it('sends isOverride=true when the source filter is set to User override', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let lastParams: URLSearchParams | null = null;
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (req, res, ctx) => {
|
||||
lastParams = req.url.searchParams;
|
||||
return res(ctx.status(200), ctx.json(makeListResponse(mockRules)));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await user.click(screen.getByTestId('source-filter'));
|
||||
// Scope to the listbox option — "User override" also appears as a row badge.
|
||||
await user.click(
|
||||
await screen.findByRole('option', { name: 'User override' }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(lastParams?.get('isOverride')).toBe('true'));
|
||||
});
|
||||
|
||||
it('opens the add drawer for a manager (ADMIN)', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await user.click(screen.getByTestId('add-model-cost-btn'));
|
||||
|
||||
const modelInput = await screen.findByTestId('drawer-model-id-input');
|
||||
expect(modelInput).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the add button and row actions for a viewer', async () => {
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />, undefined, { role: 'VIEWER' });
|
||||
|
||||
const row = (
|
||||
await screen.findByTestId('model-cell-name-rule-openai')
|
||||
).closest('tr') as HTMLElement;
|
||||
expect(screen.queryByTestId('add-model-cost-btn')).not.toBeInTheDocument();
|
||||
// View-only rows render no action menu (no buttons in the row).
|
||||
expect(within(row).queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the edit drawer prefilled from the row action menu', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await openRowMenu(user, 'rule-openai');
|
||||
await user.click(await screen.findByText('Edit'));
|
||||
|
||||
const drawerTitle = await screen.findByText('Edit model cost');
|
||||
expect(drawerTitle).toBeInTheDocument();
|
||||
const modelInput = screen.getByTestId(
|
||||
'drawer-model-id-input',
|
||||
) as HTMLInputElement;
|
||||
expect(modelInput.value).toBe('gpt-4o');
|
||||
expect(modelInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('deletes a rule through the confirm dialog', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let deletedId: string | null = null;
|
||||
setupList();
|
||||
server.use(
|
||||
rest.delete(LLM_PRICING_RULE_ENDPOINT, (req, res, ctx) => {
|
||||
deletedId = req.params.id as string;
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await openRowMenu(user, 'rule-openai');
|
||||
await user.click(await screen.findByText('Delete'));
|
||||
|
||||
await user.click(await screen.findByTestId('drawer-delete-confirm-btn'));
|
||||
|
||||
await waitFor(() => expect(deletedId).toBe('rule-openai'));
|
||||
await waitFor(() =>
|
||||
expect(toastSuccess).toHaveBeenCalledWith(TOAST_MODEL_COST_DELETED),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders cache buckets for rules that have cache pricing', async () => {
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const anthropicRow = (
|
||||
await screen.findByTestId('model-cell-name-rule-anthropic')
|
||||
).closest('tr') as HTMLElement;
|
||||
expect(within(anthropicRow).getByText(/Cache Read/i)).toBeInTheDocument();
|
||||
expect(within(anthropicRow).getByText(/Cache Write/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats per-million prices in the row', async () => {
|
||||
setupList();
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
const openaiRow = (
|
||||
await screen.findByTestId('model-cell-name-rule-openai')
|
||||
).closest('tr') as HTMLElement;
|
||||
// mockRules gpt-4o has input cost 3 → rendered as $3.00.
|
||||
expect(within(openaiRow).getByText('$3.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends a normalized create payload when adding a rule', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let body: Record<string, unknown> | null = null;
|
||||
setupList();
|
||||
server.use(
|
||||
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
|
||||
body = await req.json();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await user.click(screen.getByTestId('add-model-cost-btn'));
|
||||
|
||||
// Leading/trailing whitespace should be trimmed off the model id.
|
||||
await user.type(
|
||||
await screen.findByTestId('drawer-model-id-input'),
|
||||
' gpt-4o-mini ',
|
||||
);
|
||||
await user.type(screen.getByTestId('drawer-input-cost'), '3');
|
||||
await user.type(screen.getByTestId('drawer-output-cost'), '9');
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
await waitFor(() => expect(body).not.toBeNull());
|
||||
// The create call submits a bulk `rules` array of normalized payloads.
|
||||
const [payload] = (
|
||||
body as unknown as {
|
||||
rules: Record<string, unknown>[];
|
||||
}
|
||||
).rules;
|
||||
expect(payload).toMatchObject({
|
||||
modelName: 'gpt-4o-mini',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 3, output: 9 },
|
||||
});
|
||||
});
|
||||
|
||||
it('sends an updated payload when editing a rule', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let body: Record<string, unknown> | null = null;
|
||||
setupList();
|
||||
server.use(
|
||||
rest.put(LLM_PRICING_ENDPOINT, async (req, res, ctx) => {
|
||||
body = await req.json();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
render(<ModelCostTabPanel />);
|
||||
|
||||
await screen.findByTestId('model-cell-name-rule-openai');
|
||||
await openRowMenu(user, 'rule-openai');
|
||||
await user.click(await screen.findByText('Edit'));
|
||||
|
||||
// Model id + provider are locked in edit mode; change the prefilled input cost.
|
||||
const inputCost = await screen.findByTestId('drawer-input-cost');
|
||||
await user.clear(inputCost);
|
||||
await user.type(inputCost, '5');
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
await waitFor(() => expect(body).not.toBeNull());
|
||||
const [payload] = (
|
||||
body as unknown as {
|
||||
rules: Record<string, unknown>[];
|
||||
}
|
||||
).rules;
|
||||
// Edit carries the rule id; disabled model/provider are still submitted and
|
||||
// the edited price flows through, while output keeps its prefilled value.
|
||||
expect(payload).toMatchObject({
|
||||
id: 'rule-openai',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
enabled: true,
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
pricing: { input: 5, output: 9 },
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(toastSuccess).toHaveBeenCalledWith(TOAST_MODEL_COST_UPDATED),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { makePricingRule } from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
|
||||
import { EMPTY_DRAFT } from 'container/LLMObservability/Settings/ModelPricing/constants';
|
||||
import { draftFromRule } from 'container/LLMObservability/Settings/ModelPricing/utils';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import ModelCostDrawer from '../ModelCostDrawer';
|
||||
|
||||
const editDraft = draftFromRule(
|
||||
makePricingRule({
|
||||
id: 'rule-openai',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
}),
|
||||
);
|
||||
|
||||
describe('ModelCostDrawer (integration)', () => {
|
||||
it('renders the add title and a save button for a manager', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Add model cost')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables save until the form is dirty', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeEnabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the model id required error and does not call onSave when the name is empty', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSave = jest.fn();
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={onSave}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
// Make the form dirty without touching the model id: add a pattern, which
|
||||
// mutates the `patterns` form field while leaving the name empty.
|
||||
await user.type(screen.getByTestId('drawer-pattern-input'), 'gpt');
|
||||
await user.click(screen.getByTestId('drawer-pattern-add-btn'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('drawer-save-btn')).toBeEnabled(),
|
||||
);
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
const error = await screen.findByText('Billing model ID is required.');
|
||||
expect(error).toBeInTheDocument();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onSave once on the happy path with valid model id and pricing', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSave = jest.fn();
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={onSave}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
|
||||
await user.type(screen.getByTestId('drawer-input-cost'), '3');
|
||||
await user.type(screen.getByTestId('drawer-output-cost'), '9');
|
||||
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('renders the edit title with disabled, prefilled model id and disabled provider', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="edit"
|
||||
initialDraft={editDraft}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Edit model cost')).toBeInTheDocument();
|
||||
const modelInput = screen.getByTestId(
|
||||
'drawer-model-id-input',
|
||||
) as HTMLInputElement;
|
||||
expect(modelInput.value).toBe('gpt-4o');
|
||||
expect(modelInput).toBeDisabled();
|
||||
expect(screen.getByTestId('drawer-provider-select')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('renders a read-only view with a Close button and no save for a viewer', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="edit"
|
||||
initialDraft={editDraft}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('View model cost')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('drawer-save-btn')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-cancel-btn')).toHaveTextContent('Close');
|
||||
});
|
||||
|
||||
it('renders the save error text', () => {
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError="boom"
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('boom')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds and removes a model pattern from the editor', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByTestId('drawer-pattern-input'), 'gpt-5');
|
||||
await user.click(screen.getByTestId('drawer-pattern-add-btn'));
|
||||
// The added pattern renders as a removable chip.
|
||||
const removeChip = screen.getByRole('button', {
|
||||
name: 'Remove pattern gpt-5',
|
||||
});
|
||||
expect(removeChip).toBeInTheDocument();
|
||||
|
||||
await user.click(removeChip);
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Remove pattern gpt-5' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a cache pricing bucket via the picker and removes it', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('drawer-add-bucket-btn'));
|
||||
await user.click(screen.getByTestId('drawer-add-bucket-cache-read'));
|
||||
|
||||
// Adding the bucket reveals its cost input and the shared cache-mode select.
|
||||
expect(screen.getByTestId('drawer-cache-read-cost')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-cache-mode')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('drawer-remove-cache-read'));
|
||||
expect(
|
||||
screen.queryByTestId('drawer-cache-read-cost'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('blocks save with a pricing error when an override rule has no input cost', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const onSave = jest.fn();
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="add"
|
||||
initialDraft={EMPTY_DRAFT}
|
||||
onClose={jest.fn()}
|
||||
onSave={onSave}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
// EMPTY_DRAFT defaults to an override with empty pricing. Fill only the
|
||||
// model id + output cost so the form is dirty but the input cost is missing.
|
||||
await user.type(screen.getByTestId('drawer-model-id-input'), 'openai:gpt-4o');
|
||||
await user.type(screen.getByTestId('drawer-output-cost'), '9');
|
||||
await user.click(screen.getByTestId('drawer-save-btn'));
|
||||
|
||||
await expect(
|
||||
screen.findByText('Input cost must be greater than 0.'),
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires confirmation to reset an override rule back to auto', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<ModelCostDrawer
|
||||
isOpen
|
||||
mode="edit"
|
||||
initialDraft={editDraft}
|
||||
onClose={jest.fn()}
|
||||
onSave={jest.fn()}
|
||||
isSaving={false}
|
||||
saveError={null}
|
||||
canManage
|
||||
/>,
|
||||
);
|
||||
|
||||
// Pricing is editable while the rule is an override.
|
||||
expect(screen.getByTestId('drawer-input-cost')).toBeEnabled();
|
||||
|
||||
// Picking "auto" surfaces a confirm step instead of applying immediately.
|
||||
await user.click(screen.getByTestId('drawer-source-auto'));
|
||||
expect(screen.getByTestId('drawer-reset-confirm-btn')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('drawer-input-cost')).toBeEnabled();
|
||||
|
||||
// Keep backs out of the reset.
|
||||
await user.click(screen.getByTestId('drawer-reset-keep-btn'));
|
||||
expect(
|
||||
screen.queryByTestId('drawer-reset-confirm-btn'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Confirming the reset flips the rule to auto and locks pricing.
|
||||
await user.click(screen.getByTestId('drawer-source-auto'));
|
||||
await user.click(screen.getByTestId('drawer-reset-confirm-btn'));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('drawer-input-cost')).toBeDisabled(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
useCreateOrUpdateLLMPricingRules,
|
||||
} from 'api/generated/services/llmpricingrules';
|
||||
|
||||
import { EMPTY_DRAFT } from '../../../../constants';
|
||||
import {
|
||||
EMPTY_DRAFT,
|
||||
TOAST_MODEL_COST_ADDED,
|
||||
TOAST_MODEL_COST_UPDATED,
|
||||
} from '../../../../constants';
|
||||
import type { DrawerDraft, DrawerMode, PricingRule } from '../../../../types';
|
||||
import { buildRulePayload, draftFromRule } from '../../../../utils';
|
||||
|
||||
@@ -76,7 +80,9 @@ export function useModelCostDrawer(): UseModelCostDrawerResult {
|
||||
await invalidateList();
|
||||
setIsOpen(false);
|
||||
setSelectedRuleId(null);
|
||||
toast.success(mode === 'edit' ? 'Model cost updated' : 'Model cost added');
|
||||
toast.success(
|
||||
mode === 'edit' ? TOAST_MODEL_COST_UPDATED : TOAST_MODEL_COST_ADDED,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Save failed';
|
||||
setSaveError(message);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
.modelCostsTable {
|
||||
margin-top: var(--spacing-8);
|
||||
--tanstack-table-row-height: 48px;
|
||||
height: calc(100vh - 250px);
|
||||
height: calc(100vh - 170px);
|
||||
overflow-y: auto;
|
||||
|
||||
:global(table) tbody tr {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useDeleteLLMPricingRule,
|
||||
} from 'api/generated/services/llmpricingrules';
|
||||
|
||||
import { TOAST_MODEL_COST_DELETED } from '../../constants';
|
||||
import type { PricingRule } from '../../types';
|
||||
|
||||
// The minimal slice of a rule the delete-confirm flow needs: the id to delete
|
||||
@@ -49,7 +50,7 @@ export function useModelCostDelete(): UseModelCostDeleteResult {
|
||||
queryKey: getListLLMPricingRulesQueryKey(),
|
||||
});
|
||||
setPendingDelete(null);
|
||||
toast.success('Model cost deleted');
|
||||
toast.success(TOAST_MODEL_COST_DELETED);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Delete failed';
|
||||
toast.error(message);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
LlmpricingruletypesLLMPricingRuleCacheModeDTO as CacheModeDTO,
|
||||
LlmpricingruletypesLLMPricingRuleUnitDTO as UnitDTO,
|
||||
type ListLLMPricingRules200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PricingRule } from '../types';
|
||||
|
||||
// Endpoint glob used by MSW handlers. The generated client hits a relative
|
||||
// `/api/v1/llm_pricing_rules`, so the `*` prefix matches regardless of base URL.
|
||||
export const LLM_PRICING_ENDPOINT = '*/api/v1/llm_pricing_rules';
|
||||
export const LLM_PRICING_RULE_ENDPOINT = '*/api/v1/llm_pricing_rules/:id';
|
||||
|
||||
// Builds a valid pricing rule, with overrides merged shallowly. Pricing is
|
||||
// replaced wholesale when provided so callers can shape cache buckets freely.
|
||||
export function makePricingRule(
|
||||
overrides: Partial<PricingRule> = {},
|
||||
): PricingRule {
|
||||
const { pricing, ...rest } = overrides;
|
||||
return {
|
||||
id: 'rule-1',
|
||||
enabled: true,
|
||||
isOverride: true,
|
||||
modelName: 'gpt-4o',
|
||||
modelPattern: ['gpt-4o'],
|
||||
orgId: 'org-1',
|
||||
provider: 'OpenAI',
|
||||
sourceId: 'source-1',
|
||||
unit: UnitDTO.per_million_tokens,
|
||||
createdAt: '2023-10-01T00:00:00.000Z',
|
||||
updatedAt: '2023-10-10T00:00:00.000Z',
|
||||
syncedAt: '2023-10-10T00:00:00.000Z',
|
||||
pricing: {
|
||||
input: 3,
|
||||
output: 9,
|
||||
...pricing,
|
||||
},
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export const mockRules: PricingRule[] = [
|
||||
makePricingRule({
|
||||
id: 'rule-openai',
|
||||
modelName: 'gpt-4o',
|
||||
provider: 'OpenAI',
|
||||
isOverride: true,
|
||||
pricing: { input: 3, output: 9 },
|
||||
}),
|
||||
makePricingRule({
|
||||
id: 'rule-anthropic',
|
||||
modelName: 'claude-3-5-sonnet',
|
||||
provider: 'Anthropic',
|
||||
isOverride: false,
|
||||
pricing: {
|
||||
input: 2,
|
||||
output: 30,
|
||||
cache: { mode: CacheModeDTO.additive, read: 3, write: 6 },
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
// Wraps items in the list response envelope the list query reads
|
||||
// (`data.data.items` / `data.data.total`).
|
||||
export function makeListResponse(
|
||||
items: PricingRule[],
|
||||
total = items.length,
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
): ListLLMPricingRules200 {
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import type { CacheBucketDef, DrawerDraft } from './types';
|
||||
|
||||
export const PAGE_SIZE = 20;
|
||||
|
||||
export const TOAST_MODEL_COST_ADDED = 'Model cost added';
|
||||
export const TOAST_MODEL_COST_UPDATED = 'Model cost updated';
|
||||
export const TOAST_MODEL_COST_DELETED = 'Model cost deleted';
|
||||
|
||||
export const PAGE_KEY = 'page';
|
||||
export const LIMIT_KEY = 'limit';
|
||||
export const SEARCH_KEY = 'search';
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
LLM_PRICING_ENDPOINT,
|
||||
makeListResponse,
|
||||
mockRules,
|
||||
} from 'container/LLMObservability/Settings/ModelPricing/__tests__/fixtures';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import LLMObservability from '../LLMObservability';
|
||||
|
||||
function setupList(items = mockRules): void {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(makeListResponse(items))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('LLMObservability (integration)', () => {
|
||||
beforeEach(() => {
|
||||
window.history.pushState(null, '', '/');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders the overview panel and the tab bar on the overview route', () => {
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('llm-observability-tabs')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('llm-observability-overview')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM Observability')).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Model pricing' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Attribute Mapping' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to the configuration route when the Model pricing tab is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Model pricing' }));
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates to the attribute mapping route when that tab is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Attribute Mapping' }));
|
||||
|
||||
expect(safeNavigateMock).toHaveBeenCalledWith(
|
||||
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the attribute mapping page on the attribute mapping route', () => {
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTestId('llm-observability-attribute-mapping-page'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the model-pricing page on the configuration route', async () => {
|
||||
setupList();
|
||||
render(<LLMObservability />, undefined, {
|
||||
initialRoute: ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByTestId('llm-observability-model-pricing-page'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { type TabItemProps } from '@signozhq/ui/tabs';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
|
||||
import LLMObservabilityAttributeMapping from '../AttributeMapping/LLMObservabilityAttributeMapping';
|
||||
import Overview from '../Overview/Overview';
|
||||
import LLMObservabilityModelPricing from '../Settings/ModelPricing/LLMObservabilityModelPricing';
|
||||
|
||||
const OVERVIEW_KEY = ROUTES.LLM_OBSERVABILITY_OVERVIEW;
|
||||
const CONFIGURATION_KEY = ROUTES.LLM_OBSERVABILITY_CONFIGURATION;
|
||||
const ATTRIBUTE_MAPPING_KEY = ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING;
|
||||
|
||||
interface UseLLMObservabilityTabsResult {
|
||||
items: TabItemProps[];
|
||||
activeTab: string;
|
||||
onTabChange: (key: string) => void;
|
||||
}
|
||||
|
||||
// Drives the top-level LLM Observability tabs. Route-driven: the active tab is
|
||||
// derived from the pathname (each tab owns a URL) and changing tabs navigates,
|
||||
// so tabs stay shareable/back-button friendly while rendering with the SigNoz
|
||||
// design-system Tabs.
|
||||
export function useLLMObservabilityTabs(): UseLLMObservabilityTabsResult {
|
||||
const { pathname } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
let activeTab: string = OVERVIEW_KEY;
|
||||
if (pathname.startsWith(CONFIGURATION_KEY)) {
|
||||
activeTab = CONFIGURATION_KEY;
|
||||
} else if (pathname.startsWith(ATTRIBUTE_MAPPING_KEY)) {
|
||||
activeTab = ATTRIBUTE_MAPPING_KEY;
|
||||
}
|
||||
|
||||
const onTabChange = useCallback(
|
||||
(key: string): void => {
|
||||
safeNavigate(key);
|
||||
},
|
||||
[safeNavigate],
|
||||
);
|
||||
|
||||
const items: TabItemProps[] = [
|
||||
{
|
||||
key: OVERVIEW_KEY,
|
||||
label: 'Overview',
|
||||
children: <Overview />,
|
||||
},
|
||||
{
|
||||
key: CONFIGURATION_KEY,
|
||||
label: 'Model pricing',
|
||||
children: <LLMObservabilityModelPricing />,
|
||||
},
|
||||
{
|
||||
key: ATTRIBUTE_MAPPING_KEY,
|
||||
label: 'Attribute Mapping',
|
||||
children: <LLMObservabilityAttributeMapping />,
|
||||
},
|
||||
];
|
||||
|
||||
return { items, activeTab, onTabChange };
|
||||
}
|
||||
@@ -34,6 +34,7 @@ function LogsPanelComponent({
|
||||
setRequestData,
|
||||
queryResponse,
|
||||
onColumnWidthsChange,
|
||||
hidePagination,
|
||||
}: LogsPanelComponentProps): JSX.Element {
|
||||
const [pageSize, setPageSize] = useState<number>(10);
|
||||
const [offset, setOffset] = useState<number>(0);
|
||||
@@ -158,7 +159,7 @@ function LogsPanelComponent({
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
{!widget.query.builder.queryData[0].limit && (
|
||||
{!hidePagination && !widget.query.builder.queryData[0].limit && (
|
||||
<div className="controller">
|
||||
<Controls
|
||||
totalCount={totalCount}
|
||||
@@ -198,6 +199,7 @@ export type LogsPanelComponentProps = {
|
||||
>;
|
||||
widget: Widgets;
|
||||
onColumnWidthsChange?: (widths: Record<string, number>) => void;
|
||||
hidePagination?: boolean;
|
||||
};
|
||||
|
||||
export default LogsPanelComponent;
|
||||
|
||||
@@ -3,17 +3,36 @@
|
||||
flex-direction: row;
|
||||
|
||||
.meter-explorer-quick-filters-section {
|
||||
width: 280px;
|
||||
// Width is owned by ResizableBox (inline style).
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.resizable-box__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.quick-filters-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.meter-explorer-content-section {
|
||||
width: 100%;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
|
||||
.explore-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -78,7 +97,9 @@
|
||||
|
||||
&.quick-filters-open {
|
||||
.meter-explorer-content-section {
|
||||
width: calc(100% - 280px);
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import cx from 'classnames';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { initialQueryMeterWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
@@ -18,6 +19,8 @@ import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { Filter } from '@signozhq/icons';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import usePanelWidth from 'periscope/components/ResizableBox/usePanelWidth';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -30,6 +33,10 @@ import { splitQueryIntoOneChartPerQuery } from './utils';
|
||||
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
const QUICK_FILTERS_DEFAULT_WIDTH = 280;
|
||||
const QUICK_FILTERS_MIN_WIDTH = 240;
|
||||
const QUICK_FILTERS_MAX_WIDTH = 500;
|
||||
|
||||
function Explorer(): JSX.Element {
|
||||
const {
|
||||
handleRunQuery,
|
||||
@@ -55,6 +62,16 @@ function Explorer(): JSX.Element {
|
||||
|
||||
const [showQuickFilters, setShowQuickFilters] = useState(true);
|
||||
|
||||
const {
|
||||
initialWidth: quickFiltersInitialWidth,
|
||||
persistWidth: persistQuickFiltersWidth,
|
||||
} = usePanelWidth({
|
||||
storageKey: LOCALSTORAGE.QUICK_FILTERS_WIDTH_METER,
|
||||
defaultWidth: QUICK_FILTERS_DEFAULT_WIDTH,
|
||||
minWidth: QUICK_FILTERS_MIN_WIDTH,
|
||||
maxWidth: QUICK_FILTERS_MAX_WIDTH,
|
||||
});
|
||||
|
||||
const defaultQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
@@ -127,10 +144,19 @@ function Explorer(): JSX.Element {
|
||||
'quick-filters-open': showQuickFilters,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
<ResizableBox
|
||||
handle="right"
|
||||
defaultWidth={QUICK_FILTERS_DEFAULT_WIDTH}
|
||||
initialWidth={quickFiltersInitialWidth}
|
||||
minWidth={QUICK_FILTERS_MIN_WIDTH}
|
||||
maxWidth={QUICK_FILTERS_MAX_WIDTH}
|
||||
onResize={persistQuickFiltersWidth}
|
||||
resetToDefaultOnDoubleClick
|
||||
withHandle
|
||||
className={cx('meter-explorer-quick-filters-section', {
|
||||
hidden: !showQuickFilters,
|
||||
})}
|
||||
handleTestId="quick-filters-resize-handle"
|
||||
>
|
||||
<QuickFilters
|
||||
className="qf-meter-explorer"
|
||||
@@ -142,7 +168,7 @@ function Explorer(): JSX.Element {
|
||||
setShowQuickFilters(!showQuickFilters);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ResizableBox>
|
||||
|
||||
<div className="meter-explorer-content-section">
|
||||
<div className="meter-explorer-explore-content">
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
.metrics-explorer-explore-container {
|
||||
padding-bottom: 80px;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
|
||||
.explore-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
.dashboard-navigation {
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
|
||||
.run-query-dashboard-btn {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ function ListPanelWrapper({
|
||||
queryResponse,
|
||||
setRequestData,
|
||||
onColumnWidthsChange,
|
||||
hidePagination,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const dataSource = widget.query.builder?.queryData[0]?.dataSource;
|
||||
|
||||
@@ -23,6 +24,7 @@ function ListPanelWrapper({
|
||||
queryResponse={queryResponse}
|
||||
setRequestData={setRequestData}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
hidePagination={hidePagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +34,7 @@ function ListPanelWrapper({
|
||||
queryResponse={queryResponse}
|
||||
setRequestData={setRequestData}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
hidePagination={hidePagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ function PanelWrapper({
|
||||
panelMode,
|
||||
enableDrillDown = false,
|
||||
onColumnWidthsChange,
|
||||
hidePagination,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const Component = PanelTypeVsPanelWrapper[
|
||||
selectedGraph || widget.panelTypes
|
||||
@@ -76,6 +77,7 @@ function PanelWrapper({
|
||||
enableDrillDown={enableDrillDown}
|
||||
onColumnWidthsChange={onColumnWidthsChange}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
hidePagination={hidePagination}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export type PanelWrapperProps = {
|
||||
panelMode: PanelMode;
|
||||
onColumnWidthsChange?: (widths: Record<string, number>) => void;
|
||||
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
|
||||
hidePagination?: boolean;
|
||||
};
|
||||
|
||||
export type TooltipData = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import EmptyWidget from 'container/GridCardLayout/EmptyWidget';
|
||||
@@ -6,11 +6,12 @@ import WidgetGraphComponent from 'container/GridCardLayout/GridCard/WidgetGraphC
|
||||
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { getGraphType } from 'utils/getGraphType';
|
||||
import { getSortedSeriesData } from 'utils/getSortedSeriesData';
|
||||
|
||||
import { getPublicPanelRequestData } from './utils';
|
||||
|
||||
function Panel({
|
||||
widget,
|
||||
index,
|
||||
@@ -27,54 +28,27 @@ function Panel({
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const updatedQuery = widget?.query;
|
||||
|
||||
const requestData: GetQueryResultsProps = useMemo(() => {
|
||||
if (widget.panelTypes !== PANEL_TYPES.LIST) {
|
||||
return {
|
||||
selectedTime: widget?.timePreferance,
|
||||
graphType: getGraphType(widget.panelTypes),
|
||||
// State (not memo) so LIST panels get a setRequestData — ListPanelWrapper
|
||||
// renders nothing without one.
|
||||
const [requestData, setRequestData] = useState<GetQueryResultsProps>(() =>
|
||||
getPublicPanelRequestData({ widget, startTime, endTime }),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEqual(updatedQuery, requestData.query)) {
|
||||
setRequestData((prev) => ({
|
||||
...prev,
|
||||
query: updatedQuery,
|
||||
variables: {}, // we are not supporting variables in public dashboards
|
||||
fillGaps: widget.fillSpans,
|
||||
formatForWeb: widget.panelTypes === PANEL_TYPES.TABLE,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
originalGraphType: widget.panelTypes,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
const initialDataSource = updatedQuery.builder.queryData[0].dataSource;
|
||||
const updatedQueryForList = {
|
||||
...updatedQuery,
|
||||
builder: {
|
||||
...updatedQuery.builder,
|
||||
queryData: updatedQuery.builder.queryData.map((qd, i) =>
|
||||
i === 0 ? { ...qd, pageSize: 10 } : qd,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
query: updatedQueryForList,
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
|
||||
tableParams: {
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: updatedQuery.builder.queryData[0].limit || 0,
|
||||
},
|
||||
// we do not need select columns in case of logs
|
||||
selectColumns:
|
||||
initialDataSource === DataSource.TRACES && widget.selectedTracesFields,
|
||||
},
|
||||
fillGaps: widget.fillSpans,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
};
|
||||
}, [widget, updatedQuery, startTime, endTime]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [updatedQuery]);
|
||||
|
||||
const queryResponse = useGetQueryRange(
|
||||
{
|
||||
...requestData,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
originalGraphType: widget?.panelTypes,
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
@@ -140,6 +114,8 @@ function Panel({
|
||||
headerMenuList={[]}
|
||||
isWarning={false}
|
||||
isFetchingResponse={queryResponse.isFetching || queryResponse.isLoading}
|
||||
setRequestData={setRequestData}
|
||||
hidePagination
|
||||
onDragSelect={onDragSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -19,9 +19,14 @@ jest.mock('hooks/queryBuilder/useGetQueryRange', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const widgetGraphProps = jest.fn();
|
||||
|
||||
jest.mock('container/GridCardLayout/GridCard/WidgetGraphComponent', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="widget-graph" />,
|
||||
default: (props: { setRequestData?: unknown }): JSX.Element => {
|
||||
widgetGraphProps(props);
|
||||
return <div data-testid="widget-graph" />;
|
||||
},
|
||||
}));
|
||||
|
||||
const buildWidget = (id: string): Widgets =>
|
||||
@@ -39,6 +44,22 @@ const buildWidget = (id: string): Widgets =>
|
||||
describe('Public dashboard Panel', () => {
|
||||
beforeEach(() => {
|
||||
useGetQueryRangeMock.mockClear();
|
||||
widgetGraphProps.mockClear();
|
||||
});
|
||||
|
||||
it('forwards a setRequestData setter so LIST panels render (bug 3646)', () => {
|
||||
render(
|
||||
<Panel
|
||||
widget={buildWidget('widget-a')}
|
||||
index={0}
|
||||
dashboardId="dash-1"
|
||||
startTime={100}
|
||||
endTime={200}
|
||||
/>,
|
||||
);
|
||||
|
||||
const props = widgetGraphProps.mock.calls[0][0];
|
||||
expect(typeof props.setRequestData).toBe('function');
|
||||
});
|
||||
|
||||
it('keys each panel by widget id + index so identical queries do not collide (bug 5503)', () => {
|
||||
|
||||
53
frontend/src/container/PublicDashboardContainer/utils.ts
Normal file
53
frontend/src/container/PublicDashboardContainer/utils.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { getGraphType } from 'utils/getGraphType';
|
||||
|
||||
// Builds the useGetQueryRange payload for a public-dashboard panel, mirroring the
|
||||
// authenticated GridCard.
|
||||
export const getPublicPanelRequestData = ({
|
||||
widget,
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
widget: Widgets;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}): GetQueryResultsProps => {
|
||||
const updatedQuery = widget?.query;
|
||||
|
||||
if (widget.panelTypes !== PANEL_TYPES.LIST) {
|
||||
return {
|
||||
selectedTime: widget?.timePreferance,
|
||||
graphType: getGraphType(widget.panelTypes),
|
||||
query: updatedQuery,
|
||||
variables: {}, // we are not supporting variables in public dashboards
|
||||
fillGaps: widget.fillSpans,
|
||||
formatForWeb: widget.panelTypes === PANEL_TYPES.TABLE,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
originalGraphType: widget.panelTypes,
|
||||
};
|
||||
}
|
||||
|
||||
const initialDataSource = updatedQuery.builder.queryData[0].dataSource;
|
||||
|
||||
return {
|
||||
query: updatedQuery,
|
||||
graphType: PANEL_TYPES.LIST,
|
||||
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
|
||||
tableParams: {
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: updatedQuery.builder.queryData[0].limit || 0,
|
||||
},
|
||||
// we do not need select columns in case of logs
|
||||
selectColumns:
|
||||
initialDataSource === DataSource.TRACES && widget.selectedTracesFields,
|
||||
},
|
||||
fillGaps: widget.fillSpans,
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
};
|
||||
};
|
||||
@@ -31,8 +31,14 @@
|
||||
min-width: 32px;
|
||||
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
--periscope-btn-border-color: var(
|
||||
--query-builder-v2-border-color,
|
||||
var(--l2-border)
|
||||
);
|
||||
--periscope-btn-background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,11 @@
|
||||
border-top-left-radius: 0px !important;
|
||||
border-bottom-left-radius: 0px !important;
|
||||
|
||||
background-color: var(--l1-border) !important;
|
||||
opacity: 0.8;
|
||||
background-color: var(
|
||||
--query-builder-v2-background-color,
|
||||
var(--l2-background)
|
||||
) !important;
|
||||
margin-left: -1px;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
@@ -86,8 +89,8 @@
|
||||
border-bottom-left-radius: 3px;
|
||||
|
||||
.ant-select-selector {
|
||||
border: none;
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--query-builder-v2-border-color, none);
|
||||
background: var(--query-builder-v2-background-color, var(--l3-background));
|
||||
}
|
||||
|
||||
&.showInput {
|
||||
@@ -101,12 +104,20 @@
|
||||
.query-function-value {
|
||||
width: 70px;
|
||||
border-left: 0;
|
||||
background: var(--l2-background);
|
||||
background: var(--query-builder-v2-background-color, var(--l2-background));
|
||||
border-radius: 0;
|
||||
border: 1px solid transparent;
|
||||
border-top: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
border-bottom: 1px solid
|
||||
var(--query-builder-v2-border-color, var(--l2-border));
|
||||
height: 32px;
|
||||
|
||||
&:focus {
|
||||
border-color: transparent !important;
|
||||
border-color: var(--internal-ant-border-color-focus);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--internal-ant-border-color-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +125,7 @@
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
|
||||
border-left: 1px solid var(--l2-border);
|
||||
border-left: 1px solid var(--query-builder-v2-border-color, var(--l2-border));
|
||||
|
||||
border-top-left-radius: 0px !important;
|
||||
border-bottom-left-radius: 0px !important;
|
||||
|
||||
@@ -48,6 +48,8 @@ describe('CreateRolePage - AuthZ', () => {
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ describe('EditRolePage - AuthZ', () => {
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
|
||||
|
||||
@@ -409,6 +409,8 @@ describe('ViewRolePage - AuthZ', () => {
|
||||
isFetching: true,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [],
|
||||
refetchPermissions: jest.fn(),
|
||||
});
|
||||
|
||||
|
||||
@@ -203,8 +203,9 @@ export const routesToSkip = [
|
||||
ROUTES.METER_EXPLORER_VIEWS,
|
||||
ROUTES.METRICS_EXPLORER_VOLUME_CONTROL,
|
||||
ROUTES.SOMETHING_WENT_WRONG,
|
||||
ROUTES.LLM_OBSERVABILITY_BASE,
|
||||
ROUTES.LLM_OBSERVABILITY_MODEL_PRICING,
|
||||
ROUTES.LLM_OBSERVABILITY_OVERVIEW,
|
||||
ROUTES.LLM_OBSERVABILITY_CONFIGURATION,
|
||||
ROUTES.LLM_OBSERVABILITY_ATTRIBUTE_MAPPING,
|
||||
];
|
||||
|
||||
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];
|
||||
|
||||
@@ -37,6 +37,7 @@ function TracesTableComponent({
|
||||
queryResponse,
|
||||
setRequestData,
|
||||
onColumnWidthsChange,
|
||||
hidePagination,
|
||||
}: TracesTableComponentProps): JSX.Element {
|
||||
const [pagination, setPagination] = useState<Pagination>({
|
||||
offset: 0,
|
||||
@@ -139,34 +140,36 @@ function TracesTableComponent({
|
||||
/>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
<div className="controller">
|
||||
<Controls
|
||||
totalCount={totalCount}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
isLoading={queryResponse.isFetching}
|
||||
offset={pagination.offset}
|
||||
countPerPage={pagination.limit}
|
||||
handleNavigatePrevious={(): void => {
|
||||
handlePaginationChange({
|
||||
...pagination,
|
||||
offset: pagination.offset - pagination.limit,
|
||||
});
|
||||
}}
|
||||
handleNavigateNext={(): void => {
|
||||
handlePaginationChange({
|
||||
...pagination,
|
||||
offset: pagination.offset + pagination.limit,
|
||||
});
|
||||
}}
|
||||
handleCountItemsPerPageChange={(value): void => {
|
||||
handlePaginationChange({
|
||||
...pagination,
|
||||
limit: value,
|
||||
offset: 0,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!hidePagination && (
|
||||
<div className="controller">
|
||||
<Controls
|
||||
totalCount={totalCount}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
isLoading={queryResponse.isFetching}
|
||||
offset={pagination.offset}
|
||||
countPerPage={pagination.limit}
|
||||
handleNavigatePrevious={(): void => {
|
||||
handlePaginationChange({
|
||||
...pagination,
|
||||
offset: pagination.offset - pagination.limit,
|
||||
});
|
||||
}}
|
||||
handleNavigateNext={(): void => {
|
||||
handlePaginationChange({
|
||||
...pagination,
|
||||
offset: pagination.offset + pagination.limit,
|
||||
});
|
||||
}}
|
||||
handleCountItemsPerPageChange={(value): void => {
|
||||
handlePaginationChange({
|
||||
...pagination,
|
||||
limit: value,
|
||||
offset: 0,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -178,6 +181,7 @@ export type TracesTableComponentProps = {
|
||||
>;
|
||||
widget: Widgets;
|
||||
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
|
||||
hidePagination?: boolean;
|
||||
onColumnWidthsChange?: (widths: Record<string, number>) => void;
|
||||
};
|
||||
|
||||
|
||||
30
frontend/src/hooks/__tests__/useLogsData.test.tsx
Normal file
30
frontend/src/hooks/__tests__/useLogsData.test.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { AllTheProviders } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useLogsData } from '../useLogsData';
|
||||
|
||||
describe('useLogsData', () => {
|
||||
// Public dashboards redact the widget query (orderBy/filter/limit stripped),
|
||||
// so a LIST query can arrive with no orderBy — the hook must not crash on it.
|
||||
it('does not crash when the query has no orderBy', () => {
|
||||
const stagedQuery = {
|
||||
builder: {
|
||||
queryData: [{ dataSource: 'logs', queryName: 'A', disabled: false }],
|
||||
},
|
||||
} as unknown as Query;
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useLogsData({
|
||||
result: undefined,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
stagedQuery,
|
||||
}),
|
||||
{ wrapper: AllTheProviders },
|
||||
);
|
||||
|
||||
expect(result.current.logs).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -30,4 +30,6 @@ export const useGetFieldKeys = ({
|
||||
queryKey: ['fieldKeys', signal, name],
|
||||
queryFn: () => getFieldKeys(signal, name),
|
||||
enabled,
|
||||
// Keep prior keys during a search refetch so the dropdown doesn't blank out.
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
|
||||
export type Dimensions = {
|
||||
@@ -15,6 +15,15 @@ export function useResizeObserver<T extends HTMLElement>(
|
||||
height: ref.current?.clientHeight || 0,
|
||||
});
|
||||
|
||||
// Measure before paint so the first frame has real dimensions, not 0 (the
|
||||
// debounced observer below only catches up after paint → layout jump).
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current;
|
||||
if (node) {
|
||||
setSize({ width: node.clientWidth, height: node.clientHeight });
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = debounce(
|
||||
(entries: ResizeObserverEntry[]) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user