mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-09 16:10:40 +01:00
Compare commits
41 Commits
issue-5535
...
feat/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d20afff2c9 | ||
|
|
2f70f991df | ||
|
|
67fd0b889c | ||
|
|
84c61ad6b8 | ||
|
|
3a5e744b49 | ||
|
|
ff3ec133b4 | ||
|
|
0ea5399c15 | ||
|
|
f7aa3cee9c | ||
|
|
cd8bf27da7 | ||
|
|
dde4a3abc7 | ||
|
|
1d441b63cf | ||
|
|
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
|
||||
|
||||
@@ -116,6 +116,15 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
infraMonitoringV2 := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureUseInfraMonitoringV2, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureUseInfraMonitoringV2.String()),
|
||||
Active: infraMonitoringV2,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
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>
|
||||
|
||||
@@ -65,7 +65,9 @@ function TagKeyValueInput({
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
// Plain Enter adds the tag; let Cmd/Ctrl+Enter pass through so a host form
|
||||
// (e.g. a modal) can submit on it.
|
||||
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
@@ -93,11 +95,17 @@ function TagKeyValueInput({
|
||||
};
|
||||
|
||||
const handleEditKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
// Plain Enter commits the edit; let Cmd/Ctrl+Enter pass through so a host
|
||||
// form (e.g. a modal) can submit on it.
|
||||
if (e.key === 'Enter' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
commitEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
// Contain Escape so it cancels the inline edit instead of bubbling up and
|
||||
// closing the host drawer/modal.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cancelEdit();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export enum FeatureKeys {
|
||||
USE_JSON_BODY = 'use_json_body',
|
||||
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
|
||||
USE_DASHBOARD_V2 = 'use_dashboard_v2',
|
||||
USE_INFRA_MONITORING_V2 = 'use_infra_monitoring_v2',
|
||||
ENABLE_AI_OBSERVABILITY = 'enable_ai_observability',
|
||||
ENABLE_METRICS_REDUCTION = 'enable_metrics_reduction',
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1031,26 +1031,19 @@ function ExplorerOptions({
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
footer={null}
|
||||
onOk={onCancel(false)}
|
||||
onCancel={onCancel(false)}
|
||||
<ExportPanelContainer
|
||||
open={isExport}
|
||||
centered
|
||||
destroyOnClose
|
||||
>
|
||||
<ExportPanelContainer
|
||||
query={isOneChartPerQuery ? queryToExport : query}
|
||||
isLoading={isLoading}
|
||||
onExport={(dashboard, isNewDashboard): void => {
|
||||
if (isOneChartPerQuery && queryToExport) {
|
||||
onExport(dashboard, isNewDashboard, queryToExport);
|
||||
} else {
|
||||
onExport(dashboard, isNewDashboard);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
onClose={onCancel(false)}
|
||||
query={isOneChartPerQuery ? queryToExport : query}
|
||||
isLoading={isLoading}
|
||||
onExport={(dashboard, isNewDashboard): void => {
|
||||
if (isOneChartPerQuery && queryToExport) {
|
||||
onExport(dashboard, isNewDashboard, queryToExport);
|
||||
} else {
|
||||
onExport(dashboard, isNewDashboard);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,10 +179,8 @@ describe('ExplorerOptionWrapper', () => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the "New Dashboard" button
|
||||
const newDashboardButton = screen.getByRole('button', {
|
||||
name: /new dashboard/i,
|
||||
});
|
||||
// Click the "New dashboard" button
|
||||
const newDashboardButton = screen.getByTestId('export-panel-new-dashboard');
|
||||
await user.click(newDashboardButton);
|
||||
|
||||
// Wait for the API call to complete and onExport to be called
|
||||
@@ -229,13 +227,12 @@ describe('ExplorerOptionWrapper', () => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for dashboards to load and then click on the dashboard select dropdown
|
||||
// Wait for the dashboard select dropdown to render inside the dialog
|
||||
const modal = screen.getByRole('dialog');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
|
||||
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Get the modal and find the dashboard select dropdown within it
|
||||
const modal = screen.getByRole('dialog');
|
||||
const dashboardSelect = modal.querySelector(
|
||||
'[role="combobox"]',
|
||||
) as HTMLElement;
|
||||
@@ -251,14 +248,13 @@ describe('ExplorerOptionWrapper', () => {
|
||||
const dashboardOption = screen.getByText(mockDashboard1.data.title);
|
||||
await user.click(dashboardOption);
|
||||
|
||||
// Wait for the selection to be made and the Export button to be enabled
|
||||
// Wait for the selection to be made and the export button to be enabled
|
||||
await waitFor(() => {
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
expect(exportButton).not.toBeDisabled();
|
||||
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Click the Export button
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
// Click the export button
|
||||
const exportButton = screen.getByTestId('export-panel-export');
|
||||
await user.click(exportButton);
|
||||
|
||||
// Wait for onExport to be called with the selected dashboard
|
||||
@@ -326,13 +322,12 @@ describe('ExplorerOptionWrapper', () => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for dashboards to load and then click on the dashboard select dropdown
|
||||
// Wait for the dashboard select dropdown to render inside the dialog
|
||||
const modal = screen.getByRole('dialog');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
|
||||
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Get the modal and find the dashboard select dropdown within it
|
||||
const modal = screen.getByRole('dialog');
|
||||
const dashboardSelect = modal.querySelector(
|
||||
'[role="combobox"]',
|
||||
) as HTMLElement;
|
||||
@@ -348,14 +343,13 @@ describe('ExplorerOptionWrapper', () => {
|
||||
const dashboardOption = screen.getByText(mockDashboard.data.title);
|
||||
await user.click(dashboardOption);
|
||||
|
||||
// Wait for the selection to be made and the Export button to be enabled
|
||||
// Wait for the selection to be made and the export button to be enabled
|
||||
await waitFor(() => {
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
expect(exportButton).not.toBeDisabled();
|
||||
expect(screen.getByTestId('export-panel-export')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Click the Export button
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
// Click the export button
|
||||
const exportButton = screen.getByTestId('export-panel-export');
|
||||
await user.click(exportButton);
|
||||
|
||||
// Wait for the handleExport function to be called and navigation to occur
|
||||
|
||||
74
frontend/src/container/ExportPanel/ExportDashboardSelect.tsx
Normal file
74
frontend/src/container/ExportPanel/ExportDashboardSelect.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useMemo } from 'react';
|
||||
// eslint-disable-next-line signoz/no-antd-components
|
||||
import { Select, SelectProps } from 'antd';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
|
||||
import { getSelectOptions } from './utils';
|
||||
import styles from './ExportPanel.module.scss';
|
||||
|
||||
interface ExportDashboardSelectProps {
|
||||
dashboards: Dashboard[];
|
||||
value: string | null;
|
||||
/** The picked dashboard, pinned as an option so its label survives a later search. */
|
||||
selectedDashboard: Dashboard | null;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (dashboardId: string) => void;
|
||||
onSearch: (search: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard picker for the "Add to dashboard" dialog. Server-side search (`filterOption`
|
||||
* off, typing via `onSearch`); the selected dashboard is pinned as an option so its label
|
||||
* survives a narrowing search, and `getPopupContainer` keeps the overlay from clipping the
|
||||
* dropdown.
|
||||
*/
|
||||
function ExportDashboardSelect({
|
||||
dashboards,
|
||||
value,
|
||||
selectedDashboard,
|
||||
loading,
|
||||
disabled,
|
||||
onChange,
|
||||
onSearch,
|
||||
}: ExportDashboardSelectProps): JSX.Element {
|
||||
const options = useMemo<SelectProps['options']>(() => {
|
||||
const base = getSelectOptions(dashboards) ?? [];
|
||||
if (
|
||||
selectedDashboard &&
|
||||
!base.some((option) => option.value === selectedDashboard.id)
|
||||
) {
|
||||
return [
|
||||
{ label: selectedDashboard.data.title, value: selectedDashboard.id },
|
||||
...base,
|
||||
];
|
||||
}
|
||||
return base;
|
||||
}, [dashboards, selectedDashboard]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
className={styles.dashboardSelect}
|
||||
placeholder="Select a dashboard"
|
||||
showSearch
|
||||
filterOption={false}
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSearch={onSearch}
|
||||
data-testid="export-dashboard-select"
|
||||
options={options}
|
||||
getPopupContainer={(trigger): HTMLElement =>
|
||||
trigger.parentElement ?? document.body
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
ExportDashboardSelect.defaultProps = {
|
||||
loading: false,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export default ExportDashboardSelect;
|
||||
42
frontend/src/container/ExportPanel/ExportPanel.module.scss
Normal file
42
frontend/src/container/ExportPanel/ExportPanel.module.scss
Normal file
@@ -0,0 +1,42 @@
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.dashboardSelect {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.newDashboard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -1,127 +1,155 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from 'react-query';
|
||||
import { Button } from 'antd';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import createDashboard from 'api/v1/dashboards/create';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { useCreateExportDashboard } from 'hooks/dashboard/useCreateExportDashboard';
|
||||
import { useExportDashboards } from 'hooks/dashboard/useExportDashboards';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { ExportPanelProps } from '.';
|
||||
import {
|
||||
DashboardSelect,
|
||||
NewDashboardButton,
|
||||
SelectWrapper,
|
||||
Title,
|
||||
Wrapper,
|
||||
} from './styles';
|
||||
import { filterOptions, getSelectOptions } from './utils';
|
||||
import ExportDashboardSelect from './ExportDashboardSelect';
|
||||
import styles from './ExportPanel.module.scss';
|
||||
|
||||
export interface ExportPanelProps {
|
||||
isLoading?: boolean;
|
||||
onExport: (dashboard: Dashboard | null, isNewDashboard?: boolean) => void;
|
||||
query: Query | null;
|
||||
/** Controlled open state of the dialog. */
|
||||
open: boolean;
|
||||
/** Called when the dialog requests to close (Cancel / overlay / Esc). */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Add to dashboard" dialog: export the panel into an existing dashboard or a newly
|
||||
* created one. Navigation is the caller's job via `onExport` (flag-aware V1/V2 editor).
|
||||
*/
|
||||
function ExportPanelContainer({
|
||||
isLoading,
|
||||
onExport,
|
||||
open,
|
||||
onClose,
|
||||
}: ExportPanelProps): JSX.Element {
|
||||
const { t } = useTranslation(['dashboard']);
|
||||
|
||||
const [dashboardId, setDashboardId] = useState<string | null>(null);
|
||||
// Track the object, not just the id, so export survives a search that narrows it out.
|
||||
const [selectedDashboard, setSelectedDashboard] = useState<Dashboard | null>(
|
||||
null,
|
||||
);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
const {
|
||||
data,
|
||||
dashboards,
|
||||
isLoading: isAllDashboardsLoading,
|
||||
refetch,
|
||||
} = useGetAllDashboard();
|
||||
isFetching: isDashboardsFetching,
|
||||
} = useExportDashboards(searchText);
|
||||
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const { mutate: createNewDashboard, isLoading: createDashboardLoading } =
|
||||
useMutation(createDashboard, {
|
||||
onSuccess: (data) => {
|
||||
if (data.data) {
|
||||
onExport(data?.data, true);
|
||||
}
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
showErrorModal(error as APIError);
|
||||
},
|
||||
const { create: createNewDashboard, isLoading: createDashboardLoading } =
|
||||
useCreateExportDashboard({
|
||||
title: t('new_dashboard_title', { ns: 'dashboard' }),
|
||||
onCreated: (dashboard) => onExport(dashboard, true),
|
||||
});
|
||||
|
||||
const options = useMemo(() => getSelectOptions(data?.data || []), [data]);
|
||||
|
||||
const handleExportClick = useCallback((): void => {
|
||||
const currentSelectedDashboard = data?.data?.find(
|
||||
({ id }) => id === dashboardId,
|
||||
);
|
||||
|
||||
onExport(currentSelectedDashboard || null, false);
|
||||
}, [data, dashboardId, onExport]);
|
||||
// Reset on close so each open starts fresh (the dialog stays mounted).
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedDashboard(null);
|
||||
setSearchText('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(selectedDashboardId: string): void => {
|
||||
setDashboardId(selectedDashboardId);
|
||||
(dashboardId: string): void => {
|
||||
setSelectedDashboard(
|
||||
dashboards.find(({ id }) => id === dashboardId) ?? null,
|
||||
);
|
||||
},
|
||||
[setDashboardId],
|
||||
[dashboards],
|
||||
);
|
||||
|
||||
const handleNewDashboard = useCallback(async () => {
|
||||
try {
|
||||
await createNewDashboard({
|
||||
title: t('new_dashboard_title', {
|
||||
ns: 'dashboard',
|
||||
}),
|
||||
uploadedGrafana: false,
|
||||
version: ENTITY_VERSION_V5,
|
||||
});
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
}, [createNewDashboard, t, showErrorModal]);
|
||||
const handleExportClick = useCallback((): void => {
|
||||
onExport(selectedDashboard, false);
|
||||
}, [selectedDashboard, onExport]);
|
||||
|
||||
const isDashboardLoading = isAllDashboardsLoading || createDashboardLoading;
|
||||
|
||||
const isDisabled =
|
||||
isAllDashboardsLoading || !options?.length || !dashboardId || isLoading;
|
||||
const isExportDisabled =
|
||||
isAllDashboardsLoading || !selectedDashboard || isLoading;
|
||||
|
||||
return (
|
||||
<Wrapper direction="vertical">
|
||||
<Title>Export Panel</Title>
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="Add to dashboard"
|
||||
testId="export-panel-dialog"
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
onClick={onClose}
|
||||
testId="export-panel-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
loading={isLoading}
|
||||
disabled={isExportDisabled}
|
||||
onClick={handleExportClick}
|
||||
testId="export-panel-export"
|
||||
>
|
||||
Add to dashboard
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Select a dashboard
|
||||
</Typography.Text>
|
||||
<ExportDashboardSelect
|
||||
dashboards={dashboards}
|
||||
value={selectedDashboard?.id ?? null}
|
||||
selectedDashboard={selectedDashboard}
|
||||
loading={isDashboardsFetching}
|
||||
disabled={isAllDashboardsLoading || createDashboardLoading}
|
||||
onChange={handleSelect}
|
||||
onSearch={setSearchText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SelectWrapper direction="horizontal">
|
||||
<DashboardSelect
|
||||
placeholder="Select Dashboard"
|
||||
options={options}
|
||||
showSearch
|
||||
loading={isDashboardLoading}
|
||||
disabled={isDashboardLoading}
|
||||
value={dashboardId}
|
||||
onSelect={handleSelect}
|
||||
filterOption={filterOptions}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={isLoading}
|
||||
disabled={isDisabled}
|
||||
onClick={handleExportClick}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</SelectWrapper>
|
||||
|
||||
<Typography>
|
||||
Or create dashboard with this panel -
|
||||
<NewDashboardButton
|
||||
disabled={createDashboardLoading}
|
||||
loading={createDashboardLoading}
|
||||
type="link"
|
||||
onClick={handleNewDashboard}
|
||||
>
|
||||
New Dashboard
|
||||
</NewDashboardButton>
|
||||
</Typography>
|
||||
</Wrapper>
|
||||
<div className={styles.newDashboard}>
|
||||
<Typography.Text className={styles.hint}>
|
||||
Or create a new dashboard with this panel
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<Plus size={14} />}
|
||||
loading={createDashboardLoading}
|
||||
disabled={createDashboardLoading}
|
||||
onClick={createNewDashboard}
|
||||
testId="export-panel-new-dashboard"
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
ExportPanelContainer.defaultProps = {
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
export default ExportPanelContainer;
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Modal } from 'antd';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import ExportPanelContainer from './ExportPanelContainer';
|
||||
|
||||
function ExportPanel({
|
||||
isLoading,
|
||||
onExport,
|
||||
query,
|
||||
}: ExportPanelProps): JSX.Element {
|
||||
const [isExport, setIsExport] = useState<boolean>(false);
|
||||
|
||||
const onModalToggle = useCallback((value: boolean) => {
|
||||
setIsExport(value);
|
||||
}, []);
|
||||
|
||||
const onCancel = (value: boolean) => (): void => {
|
||||
onModalToggle(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={null}
|
||||
onOk={onCancel(false)}
|
||||
onCancel={onCancel(false)}
|
||||
open={isExport}
|
||||
centered
|
||||
destroyOnClose
|
||||
>
|
||||
<ExportPanelContainer
|
||||
query={query}
|
||||
isLoading={isLoading}
|
||||
onExport={onExport}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ExportPanelProps {
|
||||
isLoading?: boolean;
|
||||
onExport: (dashboard: Dashboard | null, isNewDashboard?: boolean) => void;
|
||||
query: Query | null;
|
||||
}
|
||||
|
||||
ExportPanel.defaultProps = { isLoading: false };
|
||||
|
||||
export default ExportPanel;
|
||||
@@ -1,34 +0,0 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { Button, Select, SelectProps, Space } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const DashboardSelect: FunctionComponent<SelectProps> = styled(
|
||||
Select,
|
||||
)<SelectProps>`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const SelectWrapper = styled(Space)`
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.ant-space-item:first-child {
|
||||
width: 100%;
|
||||
max-width: 20rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Wrapper = styled(Space)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const NewDashboardButton = styled(Button)`
|
||||
&&& {
|
||||
padding: 0 0.125rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Title = styled(Typography.Text)`
|
||||
font-size: 1rem;
|
||||
`;
|
||||
@@ -6,11 +6,3 @@ export const getSelectOptions = (data: Dashboard[]): SelectProps['options'] =>
|
||||
label: data.title,
|
||||
value: id,
|
||||
}));
|
||||
|
||||
export const filterOptions: SelectProps['filterOption'] = (
|
||||
input,
|
||||
options,
|
||||
): boolean =>
|
||||
(options?.label?.toString() ?? '')
|
||||
?.toLowerCase()
|
||||
.includes(input.toLowerCase());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
|
||||
import {
|
||||
DashboardtypesListOrderDTO,
|
||||
DashboardtypesListSortDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
|
||||
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
@@ -17,6 +22,13 @@ import dialsUrl from '@/assets/Icons/dials.svg';
|
||||
|
||||
import { getItemIcon } from '../constants';
|
||||
|
||||
// The five most-recent dashboards, normalised across the v1 and v2 list APIs.
|
||||
interface RecentDashboard {
|
||||
id: string;
|
||||
title: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export default function Dashboards({
|
||||
onUpdateChecklistDoneItem,
|
||||
loadingUserPreferences,
|
||||
@@ -26,33 +38,58 @@ export default function Dashboards({
|
||||
}): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { user } = useAppContext();
|
||||
const isDashboardV2 = useIsDashboardV2();
|
||||
|
||||
const [sortedDashboards, setSortedDashboards] = useState<Dashboard[]>([]);
|
||||
|
||||
// Fetch Dashboards
|
||||
// Fetch the recent dashboards from whichever API the `use_dashboard_v2` flag
|
||||
// selects; the inactive one stays disabled so it never fires.
|
||||
const {
|
||||
data: dashboardsList,
|
||||
isLoading: isDashboardListLoading,
|
||||
isError: isDashboardListError,
|
||||
} = useGetAllDashboard();
|
||||
data: v1List,
|
||||
isLoading: v1Loading,
|
||||
isError: v1Error,
|
||||
} = useGetAllDashboard({ enabled: !isDashboardV2 });
|
||||
|
||||
const {
|
||||
data: v2List,
|
||||
isLoading: v2Loading,
|
||||
isError: v2Error,
|
||||
} = useListDashboardsForUserV2(
|
||||
{
|
||||
sort: DashboardtypesListSortDTO.updated_at,
|
||||
order: DashboardtypesListOrderDTO.desc,
|
||||
limit: 5,
|
||||
offset: 0,
|
||||
},
|
||||
{ query: { enabled: isDashboardV2 } },
|
||||
);
|
||||
|
||||
const isDashboardListLoading = isDashboardV2 ? v2Loading : v1Loading;
|
||||
const isDashboardListError = isDashboardV2 ? v2Error : v1Error;
|
||||
|
||||
const sortedDashboards = useMemo<RecentDashboard[]>(() => {
|
||||
if (isDashboardV2) {
|
||||
return (v2List?.data?.dashboards ?? []).map((d) => ({
|
||||
id: d.id,
|
||||
title: d.spec?.display?.name ?? d.name,
|
||||
tags: (d.tags ?? []).map((t) => (t.value ? `${t.key}:${t.value}` : t.key)),
|
||||
}));
|
||||
}
|
||||
return [...(v1List?.data ?? [])]
|
||||
.sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
)
|
||||
.slice(0, 5)
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
title: d.data.title,
|
||||
tags: d.data.tags ?? [],
|
||||
}));
|
||||
}, [isDashboardV2, v1List, v2List]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashboardsList) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedDashboards = dashboardsList.data.sort((a, b) => {
|
||||
const aUpdateAt = new Date(a.updatedAt).getTime();
|
||||
const bUpdateAt = new Date(b.updatedAt).getTime();
|
||||
return bUpdateAt - aUpdateAt;
|
||||
});
|
||||
|
||||
if (sortedDashboards.length > 0 && !loadingUserPreferences) {
|
||||
onUpdateChecklistDoneItem('SETUP_DASHBOARDS');
|
||||
}
|
||||
|
||||
setSortedDashboards(sortedDashboards.slice(0, 5));
|
||||
}, [dashboardsList, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
}, [sortedDashboards, onUpdateChecklistDoneItem, loadingUserPreferences]);
|
||||
|
||||
const emptyStateCard = (): JSX.Element => (
|
||||
<div className="empty-state-container">
|
||||
@@ -113,7 +150,7 @@ export default function Dashboards({
|
||||
event.stopPropagation();
|
||||
logEvent('Homepage: Dashboard clicked', {
|
||||
dashboardId: dashboard.id,
|
||||
dashboardName: dashboard.data.title,
|
||||
dashboardName: dashboard.title,
|
||||
});
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
openInNewTab(getLink());
|
||||
@@ -143,12 +180,12 @@ export default function Dashboards({
|
||||
/>
|
||||
|
||||
<div className="alert-rule-item-name home-data-item-name">
|
||||
{dashboard.data.title}
|
||||
{dashboard.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert-rule-item-description home-data-item-tag">
|
||||
{dashboard.data.tags?.map((tag) => (
|
||||
{dashboard.tags.map((tag) => (
|
||||
<Badge color="sienna" variant="outline" key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
225
frontend/src/container/InfraMonitoringHostsV2/Hosts.tsx
Normal file
225
frontend/src/container/InfraMonitoringHostsV2/Hosts.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
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/InfraMonitoringK8sV2/Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from 'container/InfraMonitoringK8sV2/Base/K8sBaseList';
|
||||
import { K8sBaseFilters } from 'container/InfraMonitoringK8sV2/Base/types';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import {
|
||||
useInfraMonitoringFiltersK8s,
|
||||
useInfraMonitoringPageListing,
|
||||
} from 'container/InfraMonitoringK8sV2/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';
|
||||
|
||||
import {
|
||||
fetchHostEntityData,
|
||||
fetchHostListData,
|
||||
getHostMetricsQueryPayload,
|
||||
hostDetailsMetadataConfig,
|
||||
hostGetEntityName,
|
||||
hostGetSelectedItemFilters,
|
||||
hostInitialEventsFilter,
|
||||
hostInitialLogTracesFilter,
|
||||
hostWidgetInfo,
|
||||
} from './constants';
|
||||
import {
|
||||
getHostItemKey,
|
||||
getHostRowKey,
|
||||
hostColumnsConfig,
|
||||
} from './table.config';
|
||||
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();
|
||||
|
||||
const { featureFlags } = useAppContext();
|
||||
const dotMetricsEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
|
||||
?.active || false;
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { handleChangeQueryData } = useQueryOperations({
|
||||
index: 0,
|
||||
query: currentQuery.builder.queryData[0],
|
||||
entityVersion: '',
|
||||
});
|
||||
|
||||
// Track previous urlFilters to only sync when the value actually changes
|
||||
// (not when handleChangeQueryData changes due to query updates)
|
||||
const prevUrlFiltersRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const currentFiltersJson = urlFilters ? JSON.stringify(urlFilters) : null;
|
||||
|
||||
// Only sync if urlFilters value has actually changed
|
||||
if (prevUrlFiltersRef.current !== currentFiltersJson) {
|
||||
prevUrlFiltersRef.current = currentFiltersJson;
|
||||
// Sync filters to query builder, using empty filter when urlFilters is null
|
||||
handleChangeQueryData('filters', urlFilters || { items: [], op: 'and' });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [urlFilters]); // handleChangeQueryData intentionally omitted - we call the current version but don't re-run when it changes
|
||||
|
||||
const handleFilterVisibilityChange = (): void => {
|
||||
setShowFilters(!showFilters);
|
||||
};
|
||||
|
||||
const handleQuickFiltersChange = (query: Query): void => {
|
||||
const filters = query.builder.queryData[0].filters;
|
||||
// Nuqs batches these calls into a single URL update
|
||||
// The useEffect will sync filters to query builder
|
||||
setUrlFilters(filters || null);
|
||||
setCurrentPage(1);
|
||||
logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.HostEntity,
|
||||
page: InfraMonitoringEvents.ListPage,
|
||||
});
|
||||
};
|
||||
|
||||
const fetchListData = useCallback(
|
||||
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
|
||||
filters.orderBy ||= {
|
||||
columnName: 'cpu',
|
||||
order: 'desc',
|
||||
};
|
||||
|
||||
return fetchHostListData(filters, signal);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const fetchEntityData = useCallback(
|
||||
async (
|
||||
filters: Parameters<typeof fetchHostEntityData>[0],
|
||||
signal?: AbortSignal,
|
||||
) => fetchHostEntityData(filters, signal),
|
||||
[],
|
||||
);
|
||||
|
||||
const getSelectedItemFilters = useCallback(
|
||||
(selectedItem: string) =>
|
||||
hostGetSelectedItemFilters(selectedItem, dotMetricsEnabled),
|
||||
[dotMetricsEnabled],
|
||||
);
|
||||
|
||||
const getInitialLogTracesFilters = useCallback(
|
||||
(host: import('api/infraMonitoring/getHostLists').HostData) =>
|
||||
hostInitialLogTracesFilter(host, dotMetricsEnabled),
|
||||
[dotMetricsEnabled],
|
||||
);
|
||||
|
||||
const controlListPrefix = !showFilters ? (
|
||||
<div className={styles.quickFiltersToggleContainer}>
|
||||
<Button
|
||||
className="periscope-btn ghost"
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={handleFilterVisibilityChange}
|
||||
>
|
||||
<Filter size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.infraMonitoringContainer}>
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<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">
|
||||
<ArrowUpToLine
|
||||
style={{ rotate: '270deg', cursor: 'pointer' }}
|
||||
onClick={handleFilterVisibilityChange}
|
||||
size="md"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={getHostsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleQuickFiltersChange}
|
||||
/>
|
||||
</ResizableBox>
|
||||
)}
|
||||
<div
|
||||
className={`${styles.listContainer}${
|
||||
showFilters ? ` ${styles.listContainerFiltersVisible}` : ''
|
||||
}`}
|
||||
>
|
||||
<K8sBaseList
|
||||
controlListPrefix={controlListPrefix}
|
||||
entity={InfraMonitoringEntity.HOSTS}
|
||||
tableColumns={hostColumnsConfig}
|
||||
fetchListData={fetchListData}
|
||||
getRowKey={getHostRowKey}
|
||||
getItemKey={getHostItemKey}
|
||||
eventCategory={InfraMonitoringEvents.HostEntity}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<K8sBaseDetails
|
||||
category={InfraMonitoringEntity.HOSTS}
|
||||
eventCategory={InfraMonitoringEvents.HostEntity}
|
||||
getSelectedItemFilters={getSelectedItemFilters}
|
||||
fetchEntityData={fetchEntityData}
|
||||
getEntityName={hostGetEntityName}
|
||||
getInitialLogTracesFilters={getInitialLogTracesFilters}
|
||||
getInitialEventsFilters={hostInitialEventsFilter}
|
||||
metadataConfig={hostDetailsMetadataConfig}
|
||||
entityWidgetInfo={hostWidgetInfo}
|
||||
getEntityQueryPayload={getHostMetricsQueryPayload}
|
||||
queryKeyPrefix="hosts"
|
||||
tabsConfig={{
|
||||
showEvents: false,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Hosts;
|
||||
@@ -0,0 +1,171 @@
|
||||
.infraMonitoringContainer {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.infraContentRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: calc(100vh - 45px);
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
|
||||
:global(.periscope-btn) {
|
||||
&.ghost:not(:disabled) {
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
color: var(--bg-robin-500) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&::-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%);
|
||||
}
|
||||
}
|
||||
|
||||
.quickFiltersContainer {
|
||||
// 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);
|
||||
|
||||
:global(.resizable-box__content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:global(.quick-filters-container) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
padding: 0 !important;
|
||||
padding-block: 0 !important;
|
||||
|
||||
:global(.quick-filters .checkbox-filter) {
|
||||
padding-left: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.quick-filters) {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
&::-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%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quickFiltersContainerHeader {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.listContainer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
> * {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
> :global(.ant-table-wrapper) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
|
||||
:global(.ant-spin-container) {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:global(.ant-table),
|
||||
:global(.ant-table-container) {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listContainerFiltersVisible {
|
||||
// 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 {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.infraMonitoringTags {
|
||||
width: fit-content;
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
border-radius: 50px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.tagsActive {
|
||||
color: var(--bg-forest-500, #25e192);
|
||||
border: 1px solid rgba(37, 225, 146, 0.2);
|
||||
background: rgba(37, 225, 146, 0.1);
|
||||
}
|
||||
|
||||
.tagsInactive {
|
||||
color: var(--bg-slate-50, #62687c);
|
||||
border: 1px solid rgba(98, 104, 124, 0.2);
|
||||
background: rgba(98, 104, 124, 0.1);
|
||||
}
|
||||
191
frontend/src/container/InfraMonitoringHostsV2/constants.tsx
Normal file
191
frontend/src/container/InfraMonitoringHostsV2/constants.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Progress } from '@signozhq/ui/progress';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
getHostLists,
|
||||
HostData,
|
||||
HostListPayload,
|
||||
} from 'api/infraMonitoring/getHostLists';
|
||||
import {
|
||||
createFilterItem,
|
||||
K8sDetailsFilters,
|
||||
K8sDetailsMetadataConfig,
|
||||
} from 'container/InfraMonitoringK8sV2/Base/K8sBaseDetails';
|
||||
import { K8sBaseFilters } from 'container/InfraMonitoringK8sV2/Base/types';
|
||||
import {
|
||||
getHostQueryPayload,
|
||||
hostWidgetInfo,
|
||||
} from 'container/LogDetailedView/InfraMetrics/constants';
|
||||
import {
|
||||
TagFilter,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getHostListsQuery } from './utils';
|
||||
|
||||
import infraHostsStyles from './InfraMonitoringHosts.module.scss';
|
||||
|
||||
export function getProgressColor(percent: number): string {
|
||||
if (percent >= 90) {
|
||||
return Color.BG_SAKURA_500;
|
||||
}
|
||||
if (percent >= 60) {
|
||||
return Color.BG_AMBER_500;
|
||||
}
|
||||
return Color.BG_FOREST_500;
|
||||
}
|
||||
|
||||
export function getMemoryProgressColor(percent: number): string {
|
||||
if (percent >= 90) {
|
||||
return Color.BG_CHERRY_500;
|
||||
}
|
||||
if (percent >= 60) {
|
||||
return Color.BG_AMBER_500;
|
||||
}
|
||||
return Color.BG_FOREST_500;
|
||||
}
|
||||
|
||||
export const hostDetailsMetadataConfig: K8sDetailsMetadataConfig<HostData>[] = [
|
||||
{
|
||||
label: 'STATUS',
|
||||
getValue: (h): string => (h.active ? 'ACTIVE' : 'INACTIVE'),
|
||||
render: (value, h): React.ReactNode => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${infraHostsStyles.infraMonitoringTags} ${
|
||||
h.active ? infraHostsStyles.tagsActive : infraHostsStyles.tagsInactive
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'OPERATING SYSTEM',
|
||||
getValue: (h): string => h.os || '-',
|
||||
render: (value): React.ReactNode =>
|
||||
value !== '-' ? (
|
||||
<Badge variant="outline" className={infraHostsStyles.infraMonitoringTags}>
|
||||
{value}
|
||||
</Badge>
|
||||
) : (
|
||||
<Typography.Text>-</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'CPU USAGE',
|
||||
getValue: (h): number => h.cpu * 100,
|
||||
render: (value): React.ReactNode => (
|
||||
<Progress
|
||||
percent={Number(Number(value).toFixed(1))}
|
||||
strokeColor={getProgressColor(Number(value))}
|
||||
showInfo
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'MEMORY USAGE',
|
||||
getValue: (h): number => h.memory * 100,
|
||||
render: (value): React.ReactNode => (
|
||||
<Progress
|
||||
percent={Number(Number(value).toFixed(1))}
|
||||
strokeColor={getMemoryProgressColor(Number(value))}
|
||||
showInfo
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function getHostMetricsQueryPayload(
|
||||
host: HostData,
|
||||
start: number,
|
||||
end: number,
|
||||
dotMetricsEnabled: boolean,
|
||||
): ReturnType<typeof getHostQueryPayload> {
|
||||
return getHostQueryPayload(host.hostName, start, end, dotMetricsEnabled);
|
||||
}
|
||||
|
||||
export { hostWidgetInfo };
|
||||
|
||||
export function hostGetSelectedItemFilters(
|
||||
selectedItem: string,
|
||||
dotMetricsEnabled: boolean,
|
||||
): TagFilter {
|
||||
const hostKey = dotMetricsEnabled ? 'host.name' : 'host_name';
|
||||
return {
|
||||
op: 'AND',
|
||||
items: [createFilterItem(hostKey, selectedItem)],
|
||||
};
|
||||
}
|
||||
|
||||
export function hostInitialLogTracesFilter(
|
||||
host: HostData,
|
||||
dotMetricsEnabled: boolean,
|
||||
): TagFilterItem[] {
|
||||
const hostKey = dotMetricsEnabled ? 'host.name' : 'host_name';
|
||||
return [createFilterItem(hostKey, host.hostName || '')];
|
||||
}
|
||||
|
||||
export function hostInitialEventsFilter(_host: HostData): TagFilterItem[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
export const hostGetEntityName = (host: HostData): string => host.hostName;
|
||||
|
||||
export async function fetchHostListData(
|
||||
filters: K8sBaseFilters,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
data: HostData[];
|
||||
total: number;
|
||||
error?: string | null;
|
||||
rawData?: unknown;
|
||||
}> {
|
||||
const baseQuery = getHostListsQuery();
|
||||
const payload: HostListPayload = {
|
||||
...baseQuery,
|
||||
limit: filters.limit,
|
||||
offset: filters.offset,
|
||||
filters: filters.filters ?? { items: [], op: 'and' },
|
||||
orderBy: filters.orderBy,
|
||||
start: filters.start,
|
||||
end: filters.end,
|
||||
groupBy: filters.groupBy ?? [],
|
||||
};
|
||||
|
||||
const response = await getHostLists(payload, signal);
|
||||
|
||||
return {
|
||||
data: response.payload?.data?.records || [],
|
||||
total: response.payload?.data?.total || 0,
|
||||
error: response.error,
|
||||
rawData: response.payload?.data,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchHostEntityData(
|
||||
filters: K8sDetailsFilters,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ data: HostData | null; error?: string | null }> {
|
||||
const response = await getHostLists(
|
||||
{
|
||||
...getHostListsQuery(),
|
||||
filters: filters.filters,
|
||||
start: filters.start,
|
||||
end: filters.end,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
groupBy: [],
|
||||
},
|
||||
signal,
|
||||
);
|
||||
|
||||
const records = response.payload?.data?.records || [];
|
||||
|
||||
return {
|
||||
data: records.length > 0 ? records[0] : null,
|
||||
error: response.error,
|
||||
};
|
||||
}
|
||||
69
frontend/src/container/InfraMonitoringHostsV2/index.tsx
Normal file
69
frontend/src/container/InfraMonitoringHostsV2/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useEffect } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import Hosts from './Hosts';
|
||||
|
||||
function InfraMonitoringHosts(): JSX.Element {
|
||||
const {
|
||||
updateAllQueriesOperators,
|
||||
handleSetConfig,
|
||||
setSupersetQuery,
|
||||
setLastUsedQuery,
|
||||
currentQuery,
|
||||
resetQuery,
|
||||
} = useQueryBuilder();
|
||||
|
||||
useEffect(() => {
|
||||
const newQuery = updateAllQueriesOperators(
|
||||
initialQueriesMap.metrics,
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
DataSource.METRICS,
|
||||
);
|
||||
|
||||
setSupersetQuery(newQuery);
|
||||
setLastUsedQuery(0);
|
||||
handleSetConfig(PANEL_TYPES.TIME_SERIES, DataSource.METRICS);
|
||||
|
||||
return (): void => {
|
||||
setLastUsedQuery(0);
|
||||
};
|
||||
}, [
|
||||
updateAllQueriesOperators,
|
||||
setSupersetQuery,
|
||||
setLastUsedQuery,
|
||||
handleSetConfig,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const updatedCurrentQuery = {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
filters: {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
resetQuery(updatedCurrentQuery);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<Hosts />
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default InfraMonitoringHosts;
|
||||
201
frontend/src/container/InfraMonitoringHostsV2/table.config.tsx
Normal file
201
frontend/src/container/InfraMonitoringHostsV2/table.config.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { HostData } from 'api/infraMonitoring/getHostLists';
|
||||
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
|
||||
import { getGroupByEl } from 'container/InfraMonitoringK8sV2/Base/utils';
|
||||
import {
|
||||
EntityProgressBar,
|
||||
ExpandButtonWrapper,
|
||||
ValidateColumnValueWrapper,
|
||||
} from 'container/InfraMonitoringK8sV2/components';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { useInfraMonitoringGroupBy } from 'container/InfraMonitoringK8sV2/hooks';
|
||||
import EntityGroupHeader from 'container/InfraMonitoringK8sV2/Base/EntityGroupHeader';
|
||||
|
||||
import { HostnameCell } from './utils';
|
||||
|
||||
import styles from './table.module.scss';
|
||||
import { Container, Info } from '@signozhq/icons';
|
||||
|
||||
function hostRowSource(host: HostData): { meta: Record<string, string> } {
|
||||
return {
|
||||
meta: {
|
||||
...(host.meta ?? {}),
|
||||
host_name: host.hostName ?? '',
|
||||
'host.name': host.hostName ?? '',
|
||||
os_type: host.os ?? '',
|
||||
'os.type': host.os ?? '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getHostRowKey(host: HostData): string {
|
||||
return host.hostName || 'unknown';
|
||||
}
|
||||
|
||||
export function getHostItemKey(host: HostData): string {
|
||||
return host.hostName ?? '';
|
||||
}
|
||||
|
||||
function HostGroupCell({ row }: { row: HostData }): JSX.Element {
|
||||
const [groupBy] = useInfraMonitoringGroupBy();
|
||||
const synthetic = hostRowSource(row);
|
||||
return getGroupByEl(synthetic, groupBy) as JSX.Element;
|
||||
}
|
||||
|
||||
export const hostColumnsConfig: TableColumnDef<HostData>[] = [
|
||||
{
|
||||
id: 'hostGroup',
|
||||
header: (): React.ReactNode => <EntityGroupHeader title="HOST GROUP" />,
|
||||
accessorFn: (row): string => row.hostName ?? '',
|
||||
width: { min: 300 },
|
||||
enableSort: false,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
visibilityBehavior: 'hidden-on-collapse',
|
||||
cell: ({ row, isExpanded, toggleExpanded }): React.ReactNode => (
|
||||
<ExpandButtonWrapper isExpanded={isExpanded} toggleExpanded={toggleExpanded}>
|
||||
<HostGroupCell row={row} />
|
||||
</ExpandButtonWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'hostName',
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader title="Hostname" icon={<Container size={14} />} />
|
||||
),
|
||||
accessorFn: (row): string => row.hostName ?? '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
visibilityBehavior: 'hidden-on-expand',
|
||||
cell: ({ value }): React.ReactNode => (
|
||||
<HostnameCell hostName={value as string} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'active',
|
||||
header: (): React.ReactNode => (
|
||||
<div className={styles.statusHeader}>
|
||||
Status
|
||||
<Tooltip title="Sent system metrics in last 10 mins">
|
||||
<Info size="md" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
accessorFn: (row): boolean => row.active,
|
||||
width: { min: 150, default: 150 },
|
||||
enableSort: false,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const active = value as boolean;
|
||||
return (
|
||||
<Badge
|
||||
className={`${styles.statusTag} ${
|
||||
active ? styles.statusTagActive : styles.statusTagInactive
|
||||
}`}
|
||||
>
|
||||
{active ? 'ACTIVE' : 'INACTIVE'}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cpu',
|
||||
header: (): React.ReactNode => (
|
||||
<div className={styles.columnHeaderRight}>CPU Usage</div>
|
||||
),
|
||||
accessorFn: (row): number => row.cpu,
|
||||
width: { min: 220 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const cpu = value as number;
|
||||
return (
|
||||
<div className={styles.progressContainer}>
|
||||
<ValidateColumnValueWrapper
|
||||
value={cpu}
|
||||
entity={InfraMonitoringEntity.HOSTS}
|
||||
attribute="CPU metric"
|
||||
>
|
||||
<EntityProgressBar value={cpu} type="cpu" />
|
||||
</ValidateColumnValueWrapper>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'memory',
|
||||
header: (): React.ReactNode => (
|
||||
<div className={`${styles.columnHeaderRight} ${styles.memoryUsageHeader}`}>
|
||||
Memory Usage
|
||||
<Tooltip title="Excluding cache memory">
|
||||
<Info size="md" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
accessorFn: (row): number => row.memory,
|
||||
width: { min: 220 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const memory = value as number;
|
||||
return (
|
||||
<div className={styles.progressContainer}>
|
||||
<ValidateColumnValueWrapper
|
||||
value={memory}
|
||||
entity={InfraMonitoringEntity.HOSTS}
|
||||
attribute="memory metric"
|
||||
>
|
||||
<EntityProgressBar value={memory} type="memory" />
|
||||
</ValidateColumnValueWrapper>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'wait',
|
||||
header: (): React.ReactNode => (
|
||||
<div className={styles.columnHeaderRight}>IOWait</div>
|
||||
),
|
||||
accessorFn: (row): number => row.wait,
|
||||
width: { min: 100, default: 100 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const wait = value as number;
|
||||
|
||||
return (
|
||||
<ValidateColumnValueWrapper
|
||||
value={wait}
|
||||
entity={InfraMonitoringEntity.HOSTS}
|
||||
attribute="IOWait metric"
|
||||
>
|
||||
<TanStackTable.Text>{`${Number((wait * 100).toFixed(1))}%`}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'load15',
|
||||
header: (): React.ReactNode => (
|
||||
<div className={styles.columnHeaderRight}>Load Avg</div>
|
||||
),
|
||||
accessorFn: (row): number => row.load15,
|
||||
width: { min: 100, default: 100 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const load15 = value as number;
|
||||
|
||||
return (
|
||||
<ValidateColumnValueWrapper
|
||||
value={load15}
|
||||
entity={InfraMonitoringEntity.HOSTS}
|
||||
attribute="load average metric"
|
||||
>
|
||||
<TanStackTable.Text>{load15}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,68 @@
|
||||
.entityGroupHeader {
|
||||
padding-left: var(--spacing-5);
|
||||
gap: var(--spacing-5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hostnameColumnHeader {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.statusHeader {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.columnHeaderRight {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.memoryUsageHeader {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.statusTag {
|
||||
width: fit-content;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.44px;
|
||||
text-transform: uppercase;
|
||||
border-radius: 50px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.statusTagActive {
|
||||
color: var(--Forest-500, #25e192);
|
||||
border: 1px solid rgba(37, 225, 146, 0.2);
|
||||
background: rgba(37, 225, 146, 0.1);
|
||||
}
|
||||
|
||||
.statusTagInactive {
|
||||
color: var(--Slate-50, #62687c);
|
||||
border: 1px solid rgba(98, 104, 124, 0.2);
|
||||
background: rgba(98, 104, 124, 0.1);
|
||||
}
|
||||
|
||||
.progressContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
& > div {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
156
frontend/src/container/InfraMonitoringHostsV2/utils.tsx
Normal file
156
frontend/src/container/InfraMonitoringHostsV2/utils.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { HostListPayload } from 'api/infraMonitoring/getHostLists';
|
||||
import {
|
||||
FiltersType,
|
||||
IQuickFiltersConfig,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { TriangleAlert } from '@signozhq/icons';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
const HOSTNAME_DOCS_URL =
|
||||
'https://signoz.io/docs/infrastructure-monitoring/hostmetrics/#host-name-is-blankempty';
|
||||
|
||||
export function HostnameCell({
|
||||
hostName,
|
||||
}: {
|
||||
hostName?: string | null;
|
||||
}): React.ReactElement {
|
||||
const isEmpty = !hostName || !hostName.trim();
|
||||
if (!isEmpty) {
|
||||
return <div className="hostname-column-value">{hostName}</div>;
|
||||
}
|
||||
return (
|
||||
<div className="hostname-cell-missing">
|
||||
<Typography.Text color="muted" className="hostname-cell-placeholder">
|
||||
-
|
||||
</Typography.Text>
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
Missing host.name metadata.
|
||||
<br />
|
||||
<a
|
||||
href={HOSTNAME_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
Learn how to configure →
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
trigger={['hover', 'focus']}
|
||||
>
|
||||
<span
|
||||
className="hostname-cell-warning-icon"
|
||||
tabIndex={0}
|
||||
role="img"
|
||||
aria-label="Missing host.name metadata"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TriangleAlert size={14} color={Color.BG_CHERRY_500} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const getHostListsQuery = (): HostListPayload => ({
|
||||
filters: {
|
||||
items: [],
|
||||
op: 'and',
|
||||
},
|
||||
groupBy: [],
|
||||
orderBy: { columnName: 'cpu', order: 'desc' },
|
||||
});
|
||||
|
||||
export const HostsQuickFiltersConfig: IQuickFiltersConfig[] = [
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Host Name',
|
||||
attributeKey: {
|
||||
key: 'host_name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: 'system_cpu_load_average_15m',
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'OS Type',
|
||||
attributeKey: {
|
||||
key: 'os_type',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: 'system_cpu_load_average_15m',
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function getHostsQuickFiltersConfig(
|
||||
dotMetricsEnabled: boolean,
|
||||
): IQuickFiltersConfig[] {
|
||||
const hostNameKey = dotMetricsEnabled ? 'host.name' : 'host_name';
|
||||
const osTypeKey = dotMetricsEnabled ? 'os.type' : 'os_type';
|
||||
const metricName = dotMetricsEnabled
|
||||
? 'system.cpu.load_average.15m'
|
||||
: 'system_cpu_load_average_15m';
|
||||
|
||||
const environmentKey = dotMetricsEnabled
|
||||
? 'deployment.environment'
|
||||
: 'deployment_environment';
|
||||
|
||||
return [
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Host Name',
|
||||
attributeKey: {
|
||||
key: hostNameKey,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: metricName,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'OS Type',
|
||||
attributeKey: {
|
||||
key: osTypeKey,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: metricName,
|
||||
dataSource: DataSource.METRICS,
|
||||
defaultOpen: true,
|
||||
},
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
title: 'Environment',
|
||||
attributeKey: {
|
||||
key: environmentKey,
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
defaultOpen: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,5 @@
|
||||
.entityGroupHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Group } from '@signozhq/icons';
|
||||
|
||||
import styles from './EntityGroupHeader.module.scss';
|
||||
|
||||
interface EntityGroupHeaderProps {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
function EntityGroupHeader({
|
||||
title,
|
||||
icon,
|
||||
}: EntityGroupHeaderProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.entityGroupHeader}>
|
||||
{icon || <Group size={14} data-hide-expanded="true" />} {title}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EntityGroupHeader;
|
||||
@@ -0,0 +1,666 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Color, Spacing } from '@signozhq/design-tokens';
|
||||
import { Button, Drawer, Tooltip } from 'antd';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import {
|
||||
CustomTimeType,
|
||||
Time,
|
||||
} from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import {
|
||||
BarChart,
|
||||
ChevronsLeftRight,
|
||||
Compass,
|
||||
DraftingCompass,
|
||||
ScrollText,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { isCustomTimeRange, useGlobalTimeStore } from 'store/globalTime';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
TagFilter,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
LogsAggregatorOperator,
|
||||
TracesAggregatorOperator,
|
||||
} from 'types/common/queryBuilder';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { InfraMonitoringEntity, VIEW_TYPES } from '../constants';
|
||||
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 EntityTraces from '../EntityDetailsUtils/EntityTraces';
|
||||
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
|
||||
import {
|
||||
useInfraMonitoringEventsFilters,
|
||||
useInfraMonitoringLogFilters,
|
||||
useInfraMonitoringSelectedItem,
|
||||
useInfraMonitoringTracesFilters,
|
||||
useInfraMonitoringView,
|
||||
} from '../hooks';
|
||||
import LoadingContainer from '../LoadingContainer';
|
||||
|
||||
import '../EntityDetailsUtils/entityDetails.styles.scss';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
|
||||
const TimeRangeOffset = 1000000000;
|
||||
|
||||
export interface K8sDetailsMetadataConfig<T> {
|
||||
label: string;
|
||||
getValue: (entity: T) => string | number;
|
||||
render?: (value: string | number, entity: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
export interface K8sDetailsFilters {
|
||||
filters: TagFilter;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface K8sBaseDetailsProps<T> {
|
||||
category: InfraMonitoringEntity;
|
||||
eventCategory: string;
|
||||
// Data fetching configuration
|
||||
getSelectedItemFilters: (selectedItem: string) => TagFilter;
|
||||
fetchEntityData: (
|
||||
filters: K8sDetailsFilters,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{ data: T | null; error?: string | null }>;
|
||||
// Entity configuration
|
||||
getEntityName: (entity: T) => string;
|
||||
getInitialLogTracesFilters: (entity: T) => TagFilterItem[];
|
||||
getInitialEventsFilters: (entity: T) => TagFilterItem[];
|
||||
metadataConfig: K8sDetailsMetadataConfig<T>[];
|
||||
entityWidgetInfo: {
|
||||
title: string;
|
||||
yAxisUnit: string;
|
||||
}[];
|
||||
getEntityQueryPayload: (
|
||||
entity: T,
|
||||
start: number,
|
||||
end: number,
|
||||
dotMetricsEnabled: boolean,
|
||||
) => GetQueryResultsProps[];
|
||||
queryKeyPrefix: string;
|
||||
/** When true, only metrics are shown and the Metrics/Logs/Traces/Events tab bar is hidden. */
|
||||
hideDetailViewTabs?: boolean;
|
||||
tabsConfig?: {
|
||||
showMetrics?: boolean;
|
||||
showLogs?: boolean;
|
||||
showTraces?: boolean;
|
||||
showEvents?: boolean;
|
||||
};
|
||||
customTabs?: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
render: (props: {
|
||||
entity: T;
|
||||
timeRange: { startTime: number; endTime: number };
|
||||
selectedInterval: Time;
|
||||
handleTimeChange: (
|
||||
interval: Time | CustomTimeType,
|
||||
dateTimeRange?: [number, number],
|
||||
) => void;
|
||||
}) => React.ReactNode;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function createFilterItem(
|
||||
key: string,
|
||||
value: string,
|
||||
dataType: DataTypes = DataTypes.String,
|
||||
): TagFilterItem {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
key: {
|
||||
key,
|
||||
dataType,
|
||||
type: 'resource',
|
||||
id: `${key}--string--resource--false`,
|
||||
},
|
||||
op: '=',
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function K8sBaseDetails<T>({
|
||||
category,
|
||||
eventCategory,
|
||||
getSelectedItemFilters,
|
||||
fetchEntityData,
|
||||
getEntityName,
|
||||
getInitialLogTracesFilters,
|
||||
getInitialEventsFilters,
|
||||
metadataConfig,
|
||||
entityWidgetInfo,
|
||||
getEntityQueryPayload,
|
||||
queryKeyPrefix,
|
||||
hideDetailViewTabs = false,
|
||||
tabsConfig,
|
||||
customTabs,
|
||||
}: K8sBaseDetailsProps<T>): JSX.Element {
|
||||
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
|
||||
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
|
||||
const lastComputedMinMax = useGlobalTimeStore((s) => s.lastComputedMinMax);
|
||||
const getAutoRefreshQueryKey = useGlobalTimeStore(
|
||||
(s) => s.getAutoRefreshQueryKey,
|
||||
);
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const [selectedItem, setSelectedItem] = useInfraMonitoringSelectedItem();
|
||||
|
||||
const entityQueryKey = useMemo(
|
||||
() =>
|
||||
getAutoRefreshQueryKey(
|
||||
selectedTime,
|
||||
`${queryKeyPrefix}EntityDetails`,
|
||||
selectedItem,
|
||||
),
|
||||
[queryKeyPrefix, selectedItem, selectedTime, getAutoRefreshQueryKey],
|
||||
);
|
||||
|
||||
const {
|
||||
data: entityResponse,
|
||||
isLoading: isEntityLoading,
|
||||
isError: isEntityError,
|
||||
error: entityError,
|
||||
} = useQuery({
|
||||
queryKey: entityQueryKey,
|
||||
queryFn: ({ signal }) => {
|
||||
if (!selectedItem) {
|
||||
return { data: null };
|
||||
}
|
||||
const filters = getSelectedItemFilters(selectedItem);
|
||||
const { minTime, maxTime } = getMinMaxTime();
|
||||
|
||||
return fetchEntityData(
|
||||
{
|
||||
filters,
|
||||
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
|
||||
},
|
||||
signal,
|
||||
);
|
||||
},
|
||||
enabled: !!selectedItem,
|
||||
});
|
||||
|
||||
const entity = entityResponse?.data ?? null;
|
||||
const hasResponseError = !!entityResponse?.error;
|
||||
|
||||
const logsAndTracesInitialExpression = useMemo(() => {
|
||||
if (!entity) {
|
||||
return '';
|
||||
}
|
||||
const primaryFiltersOnly = {
|
||||
op: 'AND' as const,
|
||||
items: getInitialLogTracesFilters(entity),
|
||||
};
|
||||
return convertFiltersToExpression(primaryFiltersOnly).expression;
|
||||
}, [entity, getInitialLogTracesFilters]);
|
||||
|
||||
const eventsInitialExpression = useMemo(() => {
|
||||
if (!entity) {
|
||||
return '';
|
||||
}
|
||||
const primaryFiltersOnly = {
|
||||
op: 'AND' as const,
|
||||
items: getInitialEventsFilters(entity),
|
||||
};
|
||||
return convertFiltersToExpression(primaryFiltersOnly).expression;
|
||||
}, [entity, getInitialEventsFilters]);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setSelectedItem(null);
|
||||
}, [setSelectedItem]);
|
||||
|
||||
const entityName = entity ? getEntityName(entity) : '';
|
||||
|
||||
// Content state (previously in K8sBaseDetailsContent)
|
||||
const tabVisibility = useMemo(
|
||||
() => ({
|
||||
showMetrics: true,
|
||||
showLogs: true,
|
||||
showTraces: true,
|
||||
showEvents: true,
|
||||
...tabsConfig,
|
||||
}),
|
||||
[tabsConfig],
|
||||
);
|
||||
|
||||
const { startMs, endMs } = useMemo(
|
||||
() => ({
|
||||
startMs: Math.floor(lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER),
|
||||
endMs: Math.floor(lastComputedMinMax.maxTime / NANO_SECOND_MULTIPLIER),
|
||||
}),
|
||||
[lastComputedMinMax],
|
||||
);
|
||||
|
||||
const [modalTimeRange, setModalTimeRange] = useState(() => ({
|
||||
startTime: startMs,
|
||||
endTime: endMs,
|
||||
}));
|
||||
|
||||
// TODO(h4ad): Remove this and use context/zustand
|
||||
const lastSelectedInterval = useRef<Time | null>(null);
|
||||
const [selectedInterval, setSelectedInterval] = useState<Time>(
|
||||
lastSelectedInterval.current
|
||||
? lastSelectedInterval.current
|
||||
: isCustomTimeRange(selectedTime)
|
||||
? DEFAULT_TIME_RANGE
|
||||
: selectedTime,
|
||||
);
|
||||
|
||||
const [selectedView, setSelectedView] = useInfraMonitoringView();
|
||||
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
|
||||
|
||||
const [, setLogFiltersParam] = useInfraMonitoringLogFilters();
|
||||
const [, setTracesFiltersParam] = useInfraMonitoringTracesFilters();
|
||||
const [, setEventsFiltersParam] = useInfraMonitoringEventsFilters();
|
||||
const [userLogsExpression] = useQueryState(
|
||||
K8S_ENTITY_LOGS_EXPRESSION_KEY,
|
||||
parseAsString,
|
||||
);
|
||||
const [userTracesExpression] = useQueryState(
|
||||
K8S_ENTITY_TRACES_EXPRESSION_KEY,
|
||||
parseAsString,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (entity) {
|
||||
logEvent(InfraMonitoringEvents.PageVisited, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
});
|
||||
}
|
||||
}, [entity, eventCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentSelectedInterval = lastSelectedInterval.current || selectedTime;
|
||||
if (!isCustomTimeRange(currentSelectedInterval)) {
|
||||
setSelectedInterval(currentSelectedInterval);
|
||||
const { minTime, maxTime } = getMinMaxTime();
|
||||
|
||||
setModalTimeRange({
|
||||
startTime: Math.floor(minTime / TimeRangeOffset),
|
||||
endTime: Math.floor(maxTime / TimeRangeOffset),
|
||||
});
|
||||
}
|
||||
}, [getMinMaxTime, selectedTime]);
|
||||
|
||||
const handleTabChange = (value: string): void => {
|
||||
setSelectedView(value);
|
||||
setLogFiltersParam(null);
|
||||
setTracesFiltersParam(null);
|
||||
setEventsFiltersParam(null);
|
||||
logEvent(InfraMonitoringEvents.TabChanged, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
view: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimeChange = useCallback(
|
||||
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
|
||||
lastSelectedInterval.current = interval as Time;
|
||||
setSelectedInterval(interval as Time);
|
||||
|
||||
if (interval === 'custom' && dateTimeRange) {
|
||||
setModalTimeRange({
|
||||
startTime: Math.floor(dateTimeRange[0] / 1000),
|
||||
endTime: Math.floor(dateTimeRange[1] / 1000),
|
||||
});
|
||||
} else {
|
||||
const { maxTime, minTime } = GetMinMax(interval);
|
||||
|
||||
setModalTimeRange({
|
||||
startTime: Math.floor(minTime / TimeRangeOffset),
|
||||
endTime: Math.floor(maxTime / TimeRangeOffset),
|
||||
});
|
||||
}
|
||||
|
||||
logEvent(InfraMonitoringEvents.TimeUpdated, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
interval,
|
||||
view: effectiveView,
|
||||
});
|
||||
},
|
||||
[eventCategory, effectiveView],
|
||||
);
|
||||
|
||||
const handleExplorePagesRedirect = (): void => {
|
||||
const urlQuery = new URLSearchParams();
|
||||
|
||||
if (selectedInterval !== 'custom') {
|
||||
urlQuery.set(QueryParams.relativeTime, selectedInterval);
|
||||
} else {
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
urlQuery.set(QueryParams.startTime, modalTimeRange.startTime.toString());
|
||||
urlQuery.set(QueryParams.endTime, modalTimeRange.endTime.toString());
|
||||
}
|
||||
|
||||
logEvent(InfraMonitoringEvents.ExploreClicked, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.DetailedPage,
|
||||
category: eventCategory,
|
||||
view: selectedView,
|
||||
});
|
||||
|
||||
if (selectedView === VIEW_TYPES.LOGS) {
|
||||
const fullExpression = combineInitialAndUserExpression(
|
||||
logsAndTracesInitialExpression,
|
||||
userLogsExpression || '',
|
||||
);
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
aggregateOperator: LogsAggregatorOperator.NOOP,
|
||||
expression: fullExpression,
|
||||
filter: { expression: fullExpression },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
|
||||
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
|
||||
} else if (selectedView === VIEW_TYPES.TRACES) {
|
||||
const fullExpression = combineInitialAndUserExpression(
|
||||
logsAndTracesInitialExpression,
|
||||
userTracesExpression || '',
|
||||
);
|
||||
|
||||
const compositeQuery = {
|
||||
...initialQueryState,
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
...initialQueryState.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.traces,
|
||||
aggregateOperator: TracesAggregatorOperator.NOOP,
|
||||
expression: fullExpression,
|
||||
filter: { expression: fullExpression },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
|
||||
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="70%"
|
||||
title={
|
||||
<>
|
||||
<Divider type="vertical" />
|
||||
<Typography.Text className="title">
|
||||
{entityName ||
|
||||
((isEntityError || hasResponseError) &&
|
||||
'Failed to load entity details') ||
|
||||
(isEntityLoading && 'Loading...') ||
|
||||
'-'}
|
||||
</Typography.Text>
|
||||
</>
|
||||
}
|
||||
placement="right"
|
||||
onClose={handleClose}
|
||||
open={!!selectedItem}
|
||||
style={{
|
||||
overscrollBehavior: 'contain',
|
||||
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
|
||||
}}
|
||||
className="entity-detail-drawer"
|
||||
destroyOnClose
|
||||
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
|
||||
>
|
||||
{isEntityLoading && <LoadingContainer />}
|
||||
{(isEntityError || hasResponseError) && (
|
||||
<div className="entity-error-container">
|
||||
<Typography.Text color="danger">
|
||||
{entityResponse?.error ||
|
||||
(entityError instanceof Error
|
||||
? entityError.message
|
||||
: 'Failed to load entity details')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
{entity && !isEntityLoading && !hasResponseError && (
|
||||
<>
|
||||
<div className="entity-detail-drawer__entity">
|
||||
<div className="entity-details-grid">
|
||||
<div className="labels-row">
|
||||
{metadataConfig.map((config) => (
|
||||
<Typography.Text
|
||||
key={config.label}
|
||||
color="muted"
|
||||
className="entity-details-metadata-label"
|
||||
>
|
||||
{config.label}
|
||||
</Typography.Text>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="values-row">
|
||||
{metadataConfig.map((config) => {
|
||||
const value = config.getValue(entity);
|
||||
const displayValue = String(value);
|
||||
return (
|
||||
<Typography.Text
|
||||
key={config.label}
|
||||
className="entity-details-metadata-value"
|
||||
>
|
||||
{config.render ? (
|
||||
config.render(value, entity)
|
||||
) : (
|
||||
<Tooltip title={displayValue}>{displayValue}</Tooltip>
|
||||
)}
|
||||
</Typography.Text>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hideDetailViewTabs && (
|
||||
<div className="views-tabs-container">
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
className="views-tabs"
|
||||
onChange={handleTabChange}
|
||||
value={selectedView}
|
||||
items={[
|
||||
...(tabVisibility.showMetrics
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.METRICS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<BarChart size={14} />
|
||||
Metrics
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showLogs
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.LOGS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<ScrollText size={14} />
|
||||
Logs
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showTraces
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.TRACES,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<DraftingCompass size={14} />
|
||||
Traces
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(tabVisibility.showEvents
|
||||
? [
|
||||
{
|
||||
value: VIEW_TYPES.EVENTS,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<ChevronsLeftRight size={14} />
|
||||
Events
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(customTabs?.map((tab) => ({
|
||||
value: tab.key,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</div>
|
||||
),
|
||||
})) ?? []),
|
||||
]}
|
||||
/>
|
||||
|
||||
{selectedView === VIEW_TYPES.LOGS && (
|
||||
<Tooltip title="Go to Logs Explorer" placement="left">
|
||||
<Button
|
||||
icon={<Compass size={18} />}
|
||||
className="compass-button"
|
||||
onClick={handleExplorePagesRedirect}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.TRACES && (
|
||||
<Tooltip title="Go to Traces Explorer" placement="left">
|
||||
<Button
|
||||
icon={<Compass size={18} />}
|
||||
className="compass-button"
|
||||
onClick={handleExplorePagesRedirect}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectiveView === VIEW_TYPES.METRICS && (
|
||||
<EntityMetrics<T>
|
||||
entity={entity}
|
||||
selectedInterval={selectedInterval}
|
||||
timeRange={modalTimeRange}
|
||||
handleTimeChange={handleTimeChange}
|
||||
isModalTimeSelection
|
||||
entityWidgetInfo={entityWidgetInfo}
|
||||
getEntityQueryPayload={getEntityQueryPayload}
|
||||
category={category}
|
||||
queryKey={`${queryKeyPrefix}Metrics`}
|
||||
/>
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.LOGS && (
|
||||
<EntityLogs
|
||||
timeRange={modalTimeRange}
|
||||
isModalTimeSelection
|
||||
handleTimeChange={handleTimeChange}
|
||||
selectedInterval={selectedInterval}
|
||||
queryKey={`${queryKeyPrefix}Logs`}
|
||||
category={category}
|
||||
initialExpression={logsAndTracesInitialExpression}
|
||||
/>
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.TRACES && (
|
||||
<EntityTraces
|
||||
timeRange={modalTimeRange}
|
||||
isModalTimeSelection
|
||||
handleTimeChange={handleTimeChange}
|
||||
selectedInterval={selectedInterval}
|
||||
queryKey={`${queryKeyPrefix}Traces`}
|
||||
category={category}
|
||||
initialExpression={logsAndTracesInitialExpression}
|
||||
/>
|
||||
)}
|
||||
{effectiveView === VIEW_TYPES.EVENTS && tabVisibility.showEvents && (
|
||||
<EntityEvents
|
||||
timeRange={modalTimeRange}
|
||||
isModalTimeSelection
|
||||
handleTimeChange={handleTimeChange}
|
||||
selectedInterval={selectedInterval}
|
||||
category={category}
|
||||
queryKey={`${queryKeyPrefix}Events`}
|
||||
initialExpression={eventsInitialExpression}
|
||||
/>
|
||||
)}
|
||||
{customTabs?.map((tab) =>
|
||||
selectedView === tab.key ? (
|
||||
<React.Fragment key={tab.key}>
|
||||
{tab.render({
|
||||
entity,
|
||||
timeRange: modalTimeRange,
|
||||
selectedInterval,
|
||||
handleTimeChange,
|
||||
})}
|
||||
</React.Fragment>
|
||||
) : null,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
.emptyStateContainer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.k8SListTable {
|
||||
padding-left: var(--spacing-2);
|
||||
--tanstack-table-header-cell-bg: var(--l2-background);
|
||||
--tanstack-table-header-cell-color: var(--l2-foreground);
|
||||
--tanstack-table-cell-bg: var(--l2-background);
|
||||
--tanstack-table-cell-color: var(--l2-foreground);
|
||||
--tanstack-table-row-hover-bg: var(--l2-background-hover);
|
||||
--tanstack-table-row-active-bg: var(--l2-background-active);
|
||||
--tanstack-table-resize-handle-bg: var(--l2-background);
|
||||
--tanstack-table-resize-handle-hover-bg: var(--l2-border);
|
||||
--tanstack-table-row-height: 42px;
|
||||
|
||||
--tanstack-cell-padding-top-override: 5px;
|
||||
--tanstack-cell-padding-bottom-override: 5px;
|
||||
--tanstack-cell-padding-left-override: 5px;
|
||||
--tanstack-cell-padding-right-override: 5px;
|
||||
|
||||
--tanstack-expansion-first-col-padding-left: 30px;
|
||||
|
||||
&[data-has-group-by='false'] {
|
||||
--tanstack-cell-padding-left-first-column: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.paginationContainer {
|
||||
padding-bottom: var(--spacing-8);
|
||||
padding-right: var(--spacing-8);
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
297
frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx
Normal file
297
frontend/src/container/InfraMonitoringK8sV2/Base/K8sBaseList.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import TanStackTable, {
|
||||
TableColumnDef,
|
||||
useHiddenColumnIds,
|
||||
} from 'components/TanStackTableView';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import {
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS,
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
import {
|
||||
useInfraMonitoringFiltersK8s,
|
||||
useInfraMonitoringGroupBy,
|
||||
useInfraMonitoringOrderBy,
|
||||
useInfraMonitoringPageListing,
|
||||
useInfraMonitoringPageSizeListing,
|
||||
} from '../hooks';
|
||||
import { K8sEmptyState } from './K8sEmptyState';
|
||||
import { K8sExpandedRow } from './K8sExpandedRow';
|
||||
import K8sHeader from './K8sHeader';
|
||||
import { K8sBaseFilters } from './types';
|
||||
import { getGroupedByMeta } from './utils';
|
||||
|
||||
import styles from './K8sBaseList.module.scss';
|
||||
import cx from 'classnames';
|
||||
|
||||
export type K8sBaseListEmptyStateContext = {
|
||||
isError: boolean;
|
||||
error?: string | null;
|
||||
totalCount: number;
|
||||
hasFilters: boolean;
|
||||
isLoading: boolean;
|
||||
rawData?: unknown;
|
||||
};
|
||||
|
||||
/** Base type constraint for K8s entity data */
|
||||
export type K8sEntityData = { meta?: Record<string, string> };
|
||||
|
||||
export type K8sBaseListProps<T extends K8sEntityData> = {
|
||||
controlListPrefix?: React.ReactNode;
|
||||
entity: InfraMonitoringEntity;
|
||||
tableColumns: TableColumnDef<T>[];
|
||||
fetchListData: (
|
||||
filters: K8sBaseFilters,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{
|
||||
data: T[];
|
||||
total: number;
|
||||
error?: string | null;
|
||||
rawData?: unknown;
|
||||
}>;
|
||||
/** Function to get the unique key for a row. */
|
||||
getRowKey?: (record: T) => string;
|
||||
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
|
||||
getItemKey?: (record: T) => string;
|
||||
eventCategory: InfraMonitoringEvents;
|
||||
renderEmptyState?: (
|
||||
context: K8sBaseListEmptyStateContext,
|
||||
) => React.ReactNode | null;
|
||||
};
|
||||
|
||||
export function K8sBaseList<T extends K8sEntityData>({
|
||||
controlListPrefix,
|
||||
entity,
|
||||
tableColumns,
|
||||
fetchListData,
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
eventCategory,
|
||||
renderEmptyState,
|
||||
}: K8sBaseListProps<T>): JSX.Element {
|
||||
const [queryFilters] = useInfraMonitoringFiltersK8s();
|
||||
const [currentPage] = useInfraMonitoringPageListing();
|
||||
const [currentPageSize] = useInfraMonitoringPageSizeListing();
|
||||
const [groupBy] = useInfraMonitoringGroupBy();
|
||||
const [orderBy] = useInfraMonitoringOrderBy();
|
||||
const [selectedItem, setSelectedItem] = useQueryState(
|
||||
'selectedItem',
|
||||
parseAsString,
|
||||
);
|
||||
|
||||
const columnStorageKey = `k8s-${entity}-columns`;
|
||||
const hiddenColumnIds = useHiddenColumnIds(columnStorageKey);
|
||||
|
||||
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
|
||||
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
|
||||
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
|
||||
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
|
||||
const getAutoRefreshQueryKey = useGlobalTimeStore(
|
||||
(s) => s.getAutoRefreshQueryKey,
|
||||
);
|
||||
|
||||
const queryKey = useMemo(() => {
|
||||
return getAutoRefreshQueryKey(
|
||||
selectedTime,
|
||||
'k8sBaseList',
|
||||
entity,
|
||||
String(currentPageSize),
|
||||
String(currentPage),
|
||||
JSON.stringify(queryFilters),
|
||||
JSON.stringify(orderBy),
|
||||
JSON.stringify(groupBy),
|
||||
);
|
||||
}, [
|
||||
getAutoRefreshQueryKey,
|
||||
selectedTime,
|
||||
entity,
|
||||
currentPageSize,
|
||||
currentPage,
|
||||
queryFilters,
|
||||
orderBy,
|
||||
groupBy,
|
||||
]);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey,
|
||||
queryFn: ({ signal }) => {
|
||||
const { minTime, maxTime } = getMinMaxTime();
|
||||
|
||||
return fetchListData(
|
||||
{
|
||||
limit: currentPageSize,
|
||||
offset: (currentPage - 1) * currentPageSize,
|
||||
filters: queryFilters || { items: [], op: 'AND' },
|
||||
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
|
||||
orderBy: orderBy || undefined,
|
||||
groupBy: groupBy?.length > 0 ? groupBy : undefined,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
},
|
||||
refetchInterval: isRefreshEnabled ? refreshInterval : false,
|
||||
});
|
||||
|
||||
const pageData = data?.data ?? [];
|
||||
const totalCount = data?.total || 0;
|
||||
const hasFilters = (queryFilters?.items?.length ?? 0) > 0;
|
||||
|
||||
const getGroupKeyFn = useCallback(
|
||||
(item: T) => getGroupedByMeta(item, groupBy),
|
||||
[groupBy],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
logEvent(InfraMonitoringEvents.PageVisited, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.ListPage,
|
||||
category: eventCategory,
|
||||
total: totalCount,
|
||||
});
|
||||
}, [eventCategory, totalCount]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(_record: T, itemKey: string): void => {
|
||||
if (groupBy.length === 0) {
|
||||
setSelectedItem(itemKey);
|
||||
}
|
||||
|
||||
logEvent(InfraMonitoringEvents.ItemClicked, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.ListPage,
|
||||
category: eventCategory,
|
||||
});
|
||||
},
|
||||
[eventCategory, groupBy.length, setSelectedItem],
|
||||
);
|
||||
|
||||
const handleRowClickNewTab = useCallback(
|
||||
(_record: T, itemKey: string): void => {
|
||||
if (groupBy.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Build URL with selectedItem param
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('selectedItem', itemKey);
|
||||
openInNewTab(url.pathname + url.search);
|
||||
|
||||
logEvent(InfraMonitoringEvents.ItemClicked, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.ListPage,
|
||||
category: eventCategory,
|
||||
});
|
||||
},
|
||||
[eventCategory, groupBy.length],
|
||||
);
|
||||
|
||||
const isGroupedByAttribute = groupBy.length > 0;
|
||||
|
||||
// Filter columns for expanded row based on parent's hidden columns
|
||||
const expandedRowColumns = useMemo(
|
||||
() => tableColumns.filter((col) => !hiddenColumnIds.includes(col.id)),
|
||||
[tableColumns, hiddenColumnIds],
|
||||
);
|
||||
|
||||
const renderExpandedRow = useCallback(
|
||||
(
|
||||
_record: T,
|
||||
rowKey: string,
|
||||
groupMeta?: Record<string, string>,
|
||||
): JSX.Element => (
|
||||
<K8sExpandedRow<T>
|
||||
rowKey={rowKey}
|
||||
groupMeta={groupMeta}
|
||||
entity={entity}
|
||||
tableColumns={expandedRowColumns}
|
||||
fetchListData={fetchListData}
|
||||
getRowKey={getRowKey}
|
||||
getItemKey={getItemKey}
|
||||
/>
|
||||
),
|
||||
[entity, fetchListData, getRowKey, getItemKey, expandedRowColumns],
|
||||
);
|
||||
|
||||
const getRowCanExpand = useCallback(
|
||||
(): boolean => isGroupedByAttribute,
|
||||
[isGroupedByAttribute],
|
||||
);
|
||||
|
||||
const showTableLoadingState = isLoading;
|
||||
|
||||
const emptyTableMessage: React.ReactNode = renderEmptyState?.({
|
||||
isError,
|
||||
error: data?.error,
|
||||
totalCount,
|
||||
hasFilters,
|
||||
isLoading: showTableLoadingState,
|
||||
rawData: data?.rawData,
|
||||
}) || (
|
||||
<K8sEmptyState
|
||||
isError={isError}
|
||||
error={data?.error}
|
||||
isLoading={showTableLoadingState}
|
||||
rawData={data?.rawData}
|
||||
/>
|
||||
);
|
||||
|
||||
const showEmptyState = !showTableLoadingState && pageData.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<K8sHeader
|
||||
controlListPrefix={controlListPrefix}
|
||||
entity={entity}
|
||||
showAutoRefresh={!selectedItem}
|
||||
columns={tableColumns}
|
||||
columnStorageKey={columnStorageKey}
|
||||
/>
|
||||
{isError && (
|
||||
<Typography>{data?.error?.toString() || 'Something went wrong'}</Typography>
|
||||
)}
|
||||
|
||||
{showEmptyState ? (
|
||||
<div className={styles.emptyStateContainer}>{emptyTableMessage}</div>
|
||||
) : (
|
||||
<TanStackTable<T>
|
||||
data={pageData}
|
||||
columns={tableColumns}
|
||||
columnStorageKey={columnStorageKey}
|
||||
isLoading={showTableLoadingState}
|
||||
getRowKey={getRowKey}
|
||||
getItemKey={getItemKey}
|
||||
groupBy={groupBy}
|
||||
getGroupKey={getGroupKeyFn}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
renderExpandedRow={isGroupedByAttribute ? renderExpandedRow : undefined}
|
||||
getRowCanExpand={isGroupedByAttribute ? getRowCanExpand : undefined}
|
||||
className={cx(styles.k8SListTable, expandedRowColumns)}
|
||||
enableQueryParams={{
|
||||
page: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
|
||||
limit: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE_SIZE,
|
||||
orderBy: INFRA_MONITORING_K8S_PARAMS_KEYS.ORDER_BY,
|
||||
expanded: INFRA_MONITORING_K8S_PARAMS_KEYS.EXPANDED,
|
||||
}}
|
||||
pagination={{
|
||||
total: totalCount,
|
||||
defaultLimit: 10,
|
||||
defaultPage: 1,
|
||||
showTotalCount: true,
|
||||
totalCountLabel: entity.charAt(0).toUpperCase() + entity.slice(1),
|
||||
}}
|
||||
paginationClassname={styles.paginationContainer}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
.container {
|
||||
height: 30vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-3);
|
||||
width: fit-content;
|
||||
max-width: 500px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
font-size: var(--periscope-font-size-medium);
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.noDataMessage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.emptyStateSvg {
|
||||
width: 32px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.eyesEmoji {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
color: var(--danger-background);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import history from 'lib/history';
|
||||
import { LifeBuoy, TriangleAlert } from '@signozhq/icons';
|
||||
|
||||
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
|
||||
import eyesEmojiUrl from '@/assets/Images/eyesEmoji.svg';
|
||||
|
||||
import type { K8sBaseListEmptyStateContext } from './K8sBaseList';
|
||||
|
||||
import styles from './K8sEmptyState.module.scss';
|
||||
|
||||
export interface K8sListResponseMetadata {
|
||||
sentAnyHostMetricsData?: boolean;
|
||||
isSendingK8SAgentMetrics?: boolean;
|
||||
endTimeBeforeRetention?: boolean;
|
||||
}
|
||||
|
||||
type K8sEmptyStateProps = Partial<K8sBaseListEmptyStateContext>;
|
||||
|
||||
const handleContactSupport = (isCloudUser: boolean): void => {
|
||||
if (isCloudUser) {
|
||||
history.push('/support');
|
||||
} else {
|
||||
window.open('https://signoz.io/slack', '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
export function K8sEmptyState({
|
||||
isError,
|
||||
error,
|
||||
isLoading,
|
||||
rawData,
|
||||
}: K8sEmptyStateProps): JSX.Element | null {
|
||||
const { isCloudUser } = useGetTenantLicense();
|
||||
|
||||
const handleSupport = useCallback(() => {
|
||||
handleContactSupport(isCloudUser);
|
||||
}, [isCloudUser]);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isError || error) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<TriangleAlert size={32} className={styles.errorIcon} />
|
||||
<span className={styles.message}>
|
||||
{error || 'An error occurred while fetching data.'}
|
||||
</span>
|
||||
<p>
|
||||
Our team is getting on top to resolve this. Please reach out to support if
|
||||
the issue persists.
|
||||
</p>
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
onClick={handleSupport}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<LifeBuoy size={14} />}
|
||||
>
|
||||
Contact Support
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = rawData as K8sListResponseMetadata | undefined;
|
||||
|
||||
if (metadata?.sentAnyHostMetricsData === false) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<img className={styles.eyesEmoji} src={eyesEmojiUrl} alt="eyes emoji" />
|
||||
<div className={styles.noDataMessage}>
|
||||
<h5 className={styles.title}>No host metrics data received yet</h5>
|
||||
<span className={styles.message}>
|
||||
Please refer to{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/infrastructure-monitoring/hostmetrics/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
our documentation
|
||||
</a>{' '}
|
||||
to learn how to send host metrics.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (metadata?.isSendingK8SAgentMetrics) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<img className={styles.eyesEmoji} src={eyesEmojiUrl} alt="eyes emoji" />
|
||||
<span className={styles.message}>
|
||||
To see K8s metrics, upgrade to the latest version of SigNoz k8s-infra
|
||||
chart. Please contact support if you need help.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (metadata?.endTimeBeforeRetention) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<img className={styles.eyesEmoji} src={eyesEmojiUrl} alt="eyes emoji" />
|
||||
<div className={styles.noDataMessage}>
|
||||
<h5 className={styles.title}>
|
||||
Queried time range is before earliest K8s metrics
|
||||
</h5>
|
||||
<span className={styles.message}>
|
||||
Your requested end time is earlier than the earliest detected time of K8s
|
||||
metrics data, please adjust your end time.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<img
|
||||
src={emptyStateUrl}
|
||||
alt="empty-state"
|
||||
className={styles.emptyStateSvg}
|
||||
/>
|
||||
<span className={styles.message}>
|
||||
This query had no results. Edit your query and try again!
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.expandedTableContainer {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.expandedTable {
|
||||
--tanstack-table-header-cell-bg: var(--l1-background);
|
||||
--tanstack-table-header-cell-color: var(--l1-foreground);
|
||||
--tanstack-table-cell-bg: var(--l1-background);
|
||||
--tanstack-table-cell-color: var(--l1-foreground);
|
||||
--tanstack-table-row-hover-bg: var(--l1-background-hover);
|
||||
--tanstack-table-row-active-bg: var(--l1-background-active);
|
||||
--tanstack-table-resize-handle-bg: var(--l1-background);
|
||||
--tanstack-table-resize-handle-hover-bg: var(--l1-border);
|
||||
--tanstack-table-row-height: 36px;
|
||||
|
||||
--tanstack-cell-padding-left-override: 15px;
|
||||
--tanstack-cell-padding-right-override: 15px;
|
||||
|
||||
& [data-hide-expanded='true'] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.expandedTableFooter {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: var(--spacing-4);
|
||||
background-color: var(--l1-background);
|
||||
}
|
||||
|
||||
.viewAllButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: var(--spacing-4);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import TanStackTable, {
|
||||
SortState,
|
||||
TableColumnDef,
|
||||
TanStackTableStateProvider,
|
||||
} from 'components/TanStackTableView';
|
||||
import { CornerDownRight } from '@signozhq/icons';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useGlobalTimeStore } from 'store/globalTime';
|
||||
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
|
||||
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import {
|
||||
useInfraMonitoringFiltersK8s,
|
||||
useInfraMonitoringGroupBy,
|
||||
useInfraMonitoringOrderBy,
|
||||
useInfraMonitoringPageListing,
|
||||
useInfraMonitoringSelectedItem,
|
||||
} from '../hooks';
|
||||
import { K8sBaseFilters } from './types';
|
||||
|
||||
import styles from './K8sExpandedRow.module.scss';
|
||||
|
||||
const EXPANDED_ROW_LIMIT = 10;
|
||||
|
||||
export type K8sExpandedRowProps<T> = {
|
||||
/** Pre-computed row key from parent table (includes group prefix + duplicate handling) */
|
||||
rowKey: string;
|
||||
/** Group metadata for building filters */
|
||||
groupMeta?: Record<string, string>;
|
||||
entity: InfraMonitoringEntity;
|
||||
tableColumns: TableColumnDef<T>[];
|
||||
fetchListData: (
|
||||
filters: K8sBaseFilters,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<{
|
||||
data: T[];
|
||||
total: number;
|
||||
error?: string | null;
|
||||
rawData?: unknown;
|
||||
}>;
|
||||
/** Function to get the unique key for a row. */
|
||||
getRowKey?: (record: T) => string;
|
||||
/** Function to get the item key used for selection. Defaults to getRowKey if not provided. */
|
||||
getItemKey?: (record: T) => string;
|
||||
};
|
||||
|
||||
export function K8sExpandedRow<T>({
|
||||
rowKey,
|
||||
groupMeta,
|
||||
entity,
|
||||
tableColumns,
|
||||
fetchListData,
|
||||
getRowKey,
|
||||
getItemKey,
|
||||
}: K8sExpandedRowProps<T>): JSX.Element {
|
||||
const [, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
const [queryFilters, setFilters] = useInfraMonitoringFiltersK8s();
|
||||
const [, setSelectedItem] = useInfraMonitoringSelectedItem();
|
||||
const [, setMainOrderBy] = useInfraMonitoringOrderBy();
|
||||
|
||||
const orderByParamKey = useMemo(
|
||||
() => `orderBy_${rowKey.replace(/[^a-zA-Z0-9]/g, '_')}`,
|
||||
[rowKey],
|
||||
);
|
||||
const [orderBy, setOrderBy] = useQueryState(
|
||||
orderByParamKey,
|
||||
parseAsJsonNoValidate<SortState | null>()
|
||||
.withDefault(null as never)
|
||||
.withOptions({
|
||||
history: 'push',
|
||||
}),
|
||||
);
|
||||
useEffect(() => {
|
||||
return (): void => {
|
||||
void setOrderBy(null);
|
||||
};
|
||||
}, [setOrderBy]);
|
||||
|
||||
const storageKey = `k8s-${entity}-columns-expanded`;
|
||||
|
||||
const createFiltersForRecord = useCallback((): NonNullable<
|
||||
IBuilderQuery['filters']
|
||||
> => {
|
||||
const baseFilters: IBuilderQuery['filters'] = {
|
||||
items: [...(queryFilters?.items || [])],
|
||||
op: 'and',
|
||||
};
|
||||
|
||||
const metaKeys = groupMeta ?? {};
|
||||
|
||||
for (const key of Object.keys(metaKeys)) {
|
||||
const value = metaKeys[key];
|
||||
// Skip empty values to avoid creating invalid filters
|
||||
if (value === '' || value === undefined || value === null) {
|
||||
continue;
|
||||
}
|
||||
baseFilters.items.push({
|
||||
key: {
|
||||
key,
|
||||
type: 'resource',
|
||||
},
|
||||
op: '=',
|
||||
value,
|
||||
id: key,
|
||||
});
|
||||
}
|
||||
|
||||
return baseFilters;
|
||||
}, [queryFilters?.items, groupMeta]);
|
||||
|
||||
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
|
||||
const refreshInterval = useGlobalTimeStore((s) => s.refreshInterval);
|
||||
const isRefreshEnabled = useGlobalTimeStore((s) => s.isRefreshEnabled);
|
||||
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
|
||||
const getAutoRefreshQueryKey = useGlobalTimeStore(
|
||||
(s) => s.getAutoRefreshQueryKey,
|
||||
);
|
||||
|
||||
const queryKey = useMemo(() => {
|
||||
return getAutoRefreshQueryKey(
|
||||
selectedTime,
|
||||
entity,
|
||||
'k8sExpandedRow',
|
||||
JSON.stringify(groupMeta),
|
||||
rowKey,
|
||||
JSON.stringify(queryFilters),
|
||||
JSON.stringify(orderBy),
|
||||
);
|
||||
}, [
|
||||
getAutoRefreshQueryKey,
|
||||
selectedTime,
|
||||
entity,
|
||||
groupMeta,
|
||||
rowKey,
|
||||
queryFilters,
|
||||
orderBy,
|
||||
]);
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async ({ signal }) => {
|
||||
const { minTime, maxTime } = getMinMaxTime();
|
||||
|
||||
return await fetchListData(
|
||||
{
|
||||
limit: EXPANDED_ROW_LIMIT,
|
||||
offset: 0,
|
||||
filters: createFiltersForRecord(),
|
||||
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
|
||||
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
|
||||
orderBy: orderBy || undefined,
|
||||
groupBy: undefined,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
},
|
||||
staleTime: 1000 * 60 * 30,
|
||||
refetchInterval: isRefreshEnabled ? refreshInterval : false,
|
||||
});
|
||||
|
||||
const expandedData = data?.data ?? [];
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(_row: T, itemKey: string): void => {
|
||||
setSelectedItem(itemKey);
|
||||
},
|
||||
[setSelectedItem],
|
||||
);
|
||||
|
||||
const handleViewAllClick = (): void => {
|
||||
const filters = createFiltersForRecord();
|
||||
setGroupBy([]);
|
||||
setCurrentPage(1);
|
||||
setFilters(filters);
|
||||
if (orderBy) {
|
||||
setMainOrderBy(orderBy);
|
||||
}
|
||||
};
|
||||
|
||||
const total = data?.total ?? 0;
|
||||
const hasMoreItems = total > EXPANDED_ROW_LIMIT;
|
||||
|
||||
const footerContent = hasMoreItems ? (
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
className={styles.viewAllButton}
|
||||
onClick={handleViewAllClick}
|
||||
prefix={<CornerDownRight size={14} />}
|
||||
>
|
||||
View All
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.expandedTableContainer}
|
||||
data-testid="expanded-table-container"
|
||||
>
|
||||
{isError && (
|
||||
<Typography>{data?.error?.toString() || 'Something went wrong'}</Typography>
|
||||
)}
|
||||
|
||||
<div data-testid="expanded-table">
|
||||
<TanStackTableStateProvider>
|
||||
<TanStackTable<T>
|
||||
data={expandedData}
|
||||
columns={tableColumns}
|
||||
columnStorageKey={storageKey}
|
||||
isLoading={isLoading}
|
||||
getRowKey={getRowKey}
|
||||
getItemKey={getItemKey}
|
||||
onRowClick={handleRowClick}
|
||||
enableQueryParams={{
|
||||
orderBy: orderByParamKey,
|
||||
}}
|
||||
tableScrollerProps={{
|
||||
className: styles.expandedTable,
|
||||
}}
|
||||
disableVirtualScroll
|
||||
cellTypographySize="medium"
|
||||
/>
|
||||
</TanStackTableStateProvider>
|
||||
{!isLoading && expandedData.length > 0 && (
|
||||
<div className={styles.expandedTableFooter}>{footerContent}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
.drawer {
|
||||
--dialog-description-padding: 0px 0px var(--spacing-8) 0px;
|
||||
}
|
||||
|
||||
.columnItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.columnsTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-small, 11px);
|
||||
font-weight: var(--periscope-font-weight-medium, 500);
|
||||
text-transform: uppercase;
|
||||
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
|
||||
&:not(:first-of-type) {
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
}
|
||||
|
||||
.columnsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.columnItem {
|
||||
justify-content: flex-start !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.horizontalDivider {
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DrawerWrapper } from '@signozhq/ui/drawer';
|
||||
import {
|
||||
hideColumn,
|
||||
showColumn,
|
||||
TableColumnDef,
|
||||
useHiddenColumnIds,
|
||||
} from 'components/TanStackTableView';
|
||||
|
||||
import styles from './K8sFiltersSidePanel.module.scss';
|
||||
|
||||
type ColumnPickerItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
canBeHidden: boolean;
|
||||
visibilityBehavior:
|
||||
| 'hidden-on-expand'
|
||||
| 'hidden-on-collapse'
|
||||
| 'always-visible';
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts TableColumnDef to column picker item format
|
||||
*/
|
||||
function toColumnPickerItems<T>(
|
||||
columns: TableColumnDef<T>[],
|
||||
): ColumnPickerItem[] {
|
||||
return columns.map((col) => ({
|
||||
id: col.id,
|
||||
label: typeof col.header === 'string' ? col.header : col.id,
|
||||
canBeHidden: col.canBeHidden !== false && col.enableRemove !== false,
|
||||
visibilityBehavior: col.visibilityBehavior ?? 'always-visible',
|
||||
}));
|
||||
}
|
||||
|
||||
function K8sFiltersSidePanel<TData>({
|
||||
open,
|
||||
onClose,
|
||||
columns,
|
||||
storageKey,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
columns: TableColumnDef<TData>[];
|
||||
storageKey: string;
|
||||
}): JSX.Element {
|
||||
const columnPickerItems = useMemo(
|
||||
() => toColumnPickerItems(columns),
|
||||
[columns],
|
||||
);
|
||||
const hiddenColumnIds = useHiddenColumnIds(storageKey);
|
||||
|
||||
const addedColumns = useMemo(
|
||||
() =>
|
||||
columnPickerItems.filter(
|
||||
(column) =>
|
||||
!hiddenColumnIds.includes(column.id) &&
|
||||
column.visibilityBehavior !== 'hidden-on-collapse',
|
||||
),
|
||||
[columnPickerItems, hiddenColumnIds],
|
||||
);
|
||||
|
||||
const hiddenColumns = useMemo(
|
||||
() =>
|
||||
columnPickerItems.filter((column) => hiddenColumnIds.includes(column.id)),
|
||||
[columnPickerItems, hiddenColumnIds],
|
||||
);
|
||||
|
||||
const handleRemoveColumn = (columnId: string): void => {
|
||||
hideColumn(storageKey, columnId);
|
||||
};
|
||||
|
||||
const handleAddColumn = (columnId: string): void => {
|
||||
showColumn(storageKey, columnId);
|
||||
};
|
||||
|
||||
const drawerContent = (
|
||||
<>
|
||||
<div className={styles.columnsTitle}>Added Columns (Click to remove)</div>
|
||||
|
||||
<div className={styles.columnsList}>
|
||||
{addedColumns.map((column) => (
|
||||
<div className={styles.columnItem} key={column.id}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="none"
|
||||
className={styles.columnItem}
|
||||
disabled={!column.canBeHidden}
|
||||
data-testid={`remove-column-${column.id}`}
|
||||
onClick={(): void => handleRemoveColumn(column.id)}
|
||||
>
|
||||
{column.label}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.horizontalDivider} />
|
||||
|
||||
<div className={styles.columnsTitle}>Other Columns (Click to add)</div>
|
||||
|
||||
<div className={styles.columnsList}>
|
||||
{hiddenColumns.map((column) => (
|
||||
<div className={styles.columnItem} key={column.id}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="none"
|
||||
className={styles.columnItem}
|
||||
data-can-be-added="true"
|
||||
data-testid={`add-column-${column.id}`}
|
||||
onClick={(): void => handleAddColumn(column.id)}
|
||||
tabIndex={0}
|
||||
>
|
||||
{column.label}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<DrawerWrapper
|
||||
open={open}
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="Columns"
|
||||
direction="right"
|
||||
showCloseButton
|
||||
showOverlay={false}
|
||||
className={styles.drawer}
|
||||
>
|
||||
{drawerContent}
|
||||
</DrawerWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default K8sFiltersSidePanel;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getGroupByEl } from './utils';
|
||||
import { useInfraMonitoringGroupBy } from '../hooks';
|
||||
|
||||
interface K8sEntityWithMeta {
|
||||
meta?: Record<string, string>;
|
||||
}
|
||||
|
||||
function K8sGroupCell<T extends K8sEntityWithMeta>({
|
||||
row,
|
||||
}: {
|
||||
row: T;
|
||||
}): JSX.Element {
|
||||
const [groupBy] = useInfraMonitoringGroupBy();
|
||||
return getGroupByEl(row, groupBy) as JSX.Element;
|
||||
}
|
||||
|
||||
export default K8sGroupCell;
|
||||
@@ -0,0 +1,80 @@
|
||||
.k8SListControls {
|
||||
padding: var(--spacing-4);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
|
||||
:global(.ant-select-selector) {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--border) !important;
|
||||
background-color: var(--l2-background) !important;
|
||||
|
||||
input {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:global([data-slot='badge'] .ant-typography) {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.k8SListControlsLeft {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
|
||||
.k8SQbSearchContainer {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
max-width: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
.k8SAttributeSearchContainer {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
max-width: 40%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.groupByLabel {
|
||||
min-width: max-content;
|
||||
font-size: var(--periscope-font-size-base, 13px);
|
||||
font-weight: var(--periscope-font-weight-regular, 400);
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--border);
|
||||
border-right: none;
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
|
||||
display: flex;
|
||||
height: 32px;
|
||||
padding: var(--spacing-3) var(--spacing-3) var(--spacing-3) var(--spacing-4);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.groupBySelect {
|
||||
:global(.ant-select-selector) {
|
||||
border-left: none;
|
||||
border-top-left-radius: 0px;
|
||||
border-bottom-left-radius: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.k8SListControlsRight {
|
||||
min-width: 240px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
232
frontend/src/container/InfraMonitoringK8sV2/Base/K8sHeader.tsx
Normal file
232
frontend/src/container/InfraMonitoringK8sV2/Base/K8sHeader.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Select } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { TableColumnDef } from 'components/TanStackTableView';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { SlidersHorizontal } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
GetK8sEntityToAggregateAttribute,
|
||||
InfraMonitoringEntity,
|
||||
} from '../constants';
|
||||
import {
|
||||
useInfraMonitoringFiltersK8s,
|
||||
useInfraMonitoringGroupBy,
|
||||
useInfraMonitoringPageListing,
|
||||
} from '../hooks';
|
||||
import K8sFiltersSidePanel from './K8sFiltersSidePanel';
|
||||
|
||||
import styles from './K8sHeader.module.scss';
|
||||
|
||||
interface K8sHeaderProps<TData> {
|
||||
controlListPrefix?: React.ReactNode;
|
||||
entity: InfraMonitoringEntity;
|
||||
showAutoRefresh: boolean;
|
||||
columns: TableColumnDef<TData>[];
|
||||
columnStorageKey: string;
|
||||
}
|
||||
|
||||
function K8sHeader<TData>({
|
||||
controlListPrefix,
|
||||
entity,
|
||||
showAutoRefresh,
|
||||
columns,
|
||||
columnStorageKey,
|
||||
}: K8sHeaderProps<TData>): JSX.Element {
|
||||
const [isFiltersSidePanelOpen, setIsFiltersSidePanelOpen] = useState(false);
|
||||
const [urlFilters, setUrlFilters] = useInfraMonitoringFiltersK8s();
|
||||
|
||||
const currentQuery = initialQueriesMap[DataSource.METRICS];
|
||||
|
||||
const updatedCurrentQuery = useMemo(() => {
|
||||
let { filters } = currentQuery.builder.queryData[0];
|
||||
if (urlFilters) {
|
||||
filters = urlFilters;
|
||||
}
|
||||
return {
|
||||
...currentQuery,
|
||||
builder: {
|
||||
...currentQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...currentQuery.builder.queryData[0],
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
...currentQuery.builder.queryData[0].aggregateAttribute,
|
||||
},
|
||||
filters,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}, [currentQuery, urlFilters]);
|
||||
|
||||
const query = useMemo(
|
||||
() => updatedCurrentQuery?.builder?.queryData[0] || null,
|
||||
[updatedCurrentQuery],
|
||||
);
|
||||
|
||||
const { handleChangeQueryData } = useQueryOperations({
|
||||
index: 0,
|
||||
query: currentQuery.builder.queryData[0],
|
||||
entityVersion: '',
|
||||
});
|
||||
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
const handleChangeTagFilters = useCallback(
|
||||
(value: IBuilderQuery['filters']) => {
|
||||
setUrlFilters(value || null);
|
||||
handleChangeQueryData('filters', value);
|
||||
setCurrentPage(1);
|
||||
|
||||
if (value?.items && value?.items?.length > 0) {
|
||||
logEvent(InfraMonitoringEvents.FilterApplied, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.ListPage,
|
||||
category: InfraMonitoringEvents.Pod,
|
||||
});
|
||||
}
|
||||
},
|
||||
[handleChangeQueryData, setCurrentPage, setUrlFilters],
|
||||
);
|
||||
|
||||
const { featureFlags } = useAppContext();
|
||||
const dotMetricsEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
|
||||
?.active || false;
|
||||
|
||||
const { data: groupByFiltersData, isLoading: isLoadingGroupByFilters } =
|
||||
useGetAggregateKeys(
|
||||
{
|
||||
dataSource: currentQuery.builder.queryData[0].dataSource,
|
||||
aggregateAttribute: GetK8sEntityToAggregateAttribute(
|
||||
entity,
|
||||
dotMetricsEnabled,
|
||||
),
|
||||
aggregateOperator: 'noop',
|
||||
searchText: '',
|
||||
tagType: '',
|
||||
},
|
||||
{
|
||||
queryKey: [currentQuery.builder.queryData[0].dataSource, 'noop'],
|
||||
},
|
||||
true,
|
||||
entity,
|
||||
);
|
||||
|
||||
const groupByOptions = useMemo(
|
||||
() =>
|
||||
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
|
||||
value: filter.key,
|
||||
label: filter.key,
|
||||
})) || [],
|
||||
[groupByFiltersData],
|
||||
);
|
||||
|
||||
const [groupBy, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
|
||||
const handleGroupByChange = useCallback(
|
||||
(value: IBuilderQuery['groupBy']) => {
|
||||
const newGroupBy = [];
|
||||
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const element = value[index] as unknown as string;
|
||||
|
||||
const key = groupByFiltersData?.payload?.attributeKeys?.find(
|
||||
(k) => k.key === element,
|
||||
);
|
||||
|
||||
if (key) {
|
||||
newGroupBy.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset pagination on switching to groupBy
|
||||
setCurrentPage(1);
|
||||
setGroupBy(newGroupBy);
|
||||
|
||||
logEvent(InfraMonitoringEvents.GroupByChanged, {
|
||||
entity: InfraMonitoringEvents.K8sEntity,
|
||||
page: InfraMonitoringEvents.ListPage,
|
||||
category: InfraMonitoringEvents.Pod,
|
||||
});
|
||||
},
|
||||
[groupByFiltersData, setCurrentPage, setGroupBy],
|
||||
);
|
||||
|
||||
const onClickOutside = useCallback(() => {
|
||||
setIsFiltersSidePanelOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.k8SListControls}>
|
||||
<div className={styles.k8SListControlsLeft}>
|
||||
{controlListPrefix}
|
||||
|
||||
<div className={styles.k8SQbSearchContainer}>
|
||||
<QueryBuilderSearch
|
||||
query={query as IBuilderQuery}
|
||||
onChange={handleChangeTagFilters}
|
||||
isInfraMonitoring
|
||||
disableNavigationShortcuts
|
||||
entity={entity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.k8SAttributeSearchContainer}>
|
||||
<div className={styles.groupByLabel}> Group by </div>
|
||||
<Select
|
||||
className={styles.groupBySelect}
|
||||
loading={isLoadingGroupByFilters}
|
||||
mode="multiple"
|
||||
value={groupBy}
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
placeholder="Search for attribute"
|
||||
style={{ width: '100%' }}
|
||||
options={groupByOptions}
|
||||
onChange={handleGroupByChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.k8SListControlsRight}>
|
||||
<DateTimeSelectionV2
|
||||
showAutoRefresh={showAutoRefresh}
|
||||
showRefreshText={false}
|
||||
hideShareModal
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="none"
|
||||
data-testid="k8s-list-filters-button"
|
||||
onClick={(): void => setIsFiltersSidePanelOpen(true)}
|
||||
>
|
||||
<SlidersHorizontal size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<K8sFiltersSidePanel
|
||||
open={isFiltersSidePanelOpen}
|
||||
columns={columns}
|
||||
storageKey={columnStorageKey}
|
||||
onClose={onClickOutside}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default K8sHeader;
|
||||
@@ -0,0 +1,912 @@
|
||||
import React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MemoryRouter as MemoryRouterV5 } from 'react-router-dom-v5-compat';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import {
|
||||
NuqsTestingAdapter,
|
||||
OnUrlUpdateFunction,
|
||||
UrlUpdateEvent,
|
||||
} from 'nuqs/adapters/testing';
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import store from 'store';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import { TableColumnDef } from 'components/TanStackTableView';
|
||||
|
||||
import { InfraMonitoringEntity } from '../../constants';
|
||||
import { K8sBaseList, K8sBaseListProps, K8sEntityData } from '../K8sBaseList';
|
||||
|
||||
jest.mock('utils/navigation', () => ({
|
||||
...jest.requireActual('utils/navigation'),
|
||||
openInNewTab: jest.fn(),
|
||||
}));
|
||||
|
||||
const openInNewTabMock = openInNewTab as jest.Mock;
|
||||
|
||||
// Mock Date.now to prevent flaky tests due to time-dependent values
|
||||
const MOCK_NOW = 1700000000000; // Fixed timestamp
|
||||
jest.spyOn(Date, 'now').mockReturnValue(MOCK_NOW);
|
||||
|
||||
// Mock DrawerWrapper to avoid CSS issues with jsdom
|
||||
// SyntaxError: 'div#radix-:rbv,,._dialog__content_qf8bf_22 :focus' is not a valid selector
|
||||
jest.mock('@signozhq/ui/drawer', () => {
|
||||
const actual = jest.requireActual('@signozhq/ui/drawer');
|
||||
return {
|
||||
...actual,
|
||||
DrawerWrapper: ({
|
||||
open,
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
open: boolean;
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
}): JSX.Element | null =>
|
||||
open ? (
|
||||
<div data-testid="drawer-wrapper" data-title={title}>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
};
|
||||
});
|
||||
|
||||
// Test data types that satisfy K8sEntityData constraint
|
||||
type TestItemWithTitle = {
|
||||
id: string;
|
||||
title: string;
|
||||
meta?: Record<string, string>;
|
||||
};
|
||||
type TestItem = { id: string; meta?: Record<string, string> };
|
||||
type TestItemWithName = {
|
||||
id: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
meta?: Record<string, string>;
|
||||
};
|
||||
type TestItemWithGroup = {
|
||||
id: string;
|
||||
name: string;
|
||||
group: string;
|
||||
meta?: Record<string, string>;
|
||||
};
|
||||
|
||||
// Helper to create TanStack columns for tests
|
||||
function createTestColumnsWithTitle(): TableColumnDef<TestItemWithTitle>[] {
|
||||
return [
|
||||
{
|
||||
id: 'id',
|
||||
header: (): React.ReactNode => 'Id',
|
||||
accessorFn: (row): string => row.id,
|
||||
cell: ({ value }): React.ReactNode => <>{value}</>,
|
||||
enableSort: true,
|
||||
},
|
||||
{
|
||||
id: 'title',
|
||||
header: (): React.ReactNode => 'Title',
|
||||
accessorFn: (row): string => row.title,
|
||||
cell: ({ value }): React.ReactNode => <>{value}</>,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createTestColumns(): TableColumnDef<TestItem>[] {
|
||||
return [
|
||||
{
|
||||
id: 'id',
|
||||
header: (): React.ReactNode => 'Id',
|
||||
accessorFn: (row): string => row.id,
|
||||
cell: ({ value }): React.ReactNode => <>{value}</>,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createTestColumnsWithName(): TableColumnDef<TestItemWithName>[] {
|
||||
return [
|
||||
{
|
||||
id: 'id',
|
||||
header: (): React.ReactNode => 'Id',
|
||||
accessorFn: (row): string => row.id,
|
||||
cell: ({ value }): React.ReactNode => <>{value}</>,
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: (): React.ReactNode => 'Name',
|
||||
accessorFn: (row): string => row.name,
|
||||
cell: ({ value }): React.ReactNode => <>{value}</>,
|
||||
},
|
||||
{
|
||||
id: 'desc',
|
||||
header: (): React.ReactNode => 'Description',
|
||||
accessorFn: (row): string => row.desc,
|
||||
cell: ({ value }): React.ReactNode => <>{value}</>,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createTestColumnsWithGroup(): TableColumnDef<TestItemWithGroup>[] {
|
||||
return [
|
||||
{
|
||||
id: 'id',
|
||||
header: (): React.ReactNode => 'Id',
|
||||
accessorFn: (row): string => row.id,
|
||||
cell: ({ value }): React.ReactNode => <>{value}</>,
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
header: (): React.ReactNode => 'Name',
|
||||
accessorFn: (row): string => row.name,
|
||||
cell: ({ value }): React.ReactNode => <>{value}</>,
|
||||
},
|
||||
{
|
||||
id: 'group',
|
||||
header: (): React.ReactNode => 'Group',
|
||||
accessorFn: (row): string => row.group,
|
||||
cell: ({ value }): React.ReactNode => <>{value}</>,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
|
||||
function renderComponent<T extends K8sEntityData>({
|
||||
queryParams,
|
||||
onUrlUpdate,
|
||||
...props
|
||||
}: K8sBaseListProps<T> & {
|
||||
queryParams?: Record<string, string>;
|
||||
onUrlUpdate?: OnUrlUpdateFunction;
|
||||
}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<MemoryRouterV5>
|
||||
<TimezoneProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppProvider>
|
||||
<Provider store={store}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={queryParams}
|
||||
onUrlUpdate={onUrlUpdate}
|
||||
>
|
||||
<VirtuosoMockContext.Provider
|
||||
value={{ viewportHeight: 800, itemHeight: 50 }}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<K8sBaseList {...props} />
|
||||
</TooltipProvider>
|
||||
</VirtuosoMockContext.Provider>
|
||||
</NuqsTestingAdapter>
|
||||
</Provider>
|
||||
</AppProvider>
|
||||
</QueryClientProvider>
|
||||
</TimezoneProvider>
|
||||
</MemoryRouterV5>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('K8sBaseList', () => {
|
||||
describe('with items in the list', () => {
|
||||
const itemId = Math.random().toString(36).slice(7);
|
||||
const itemId2 = Math.random().toString(36).slice(7);
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<K8sBaseListProps<TestItemWithTitle>['fetchListData']>,
|
||||
Parameters<K8sBaseListProps<TestItemWithTitle>['fetchListData']>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
fetchListDataMock.mockClear();
|
||||
openInNewTabMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [
|
||||
{ id: `PodId:${itemId}`, title: `PodTitle:${itemId}` },
|
||||
{ id: `PodId:${itemId2}`, title: `PodTitle:${itemId2}` },
|
||||
],
|
||||
total: 25,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderComponent<TestItemWithTitle>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumnsWithTitle(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render all the items in the list', async () => {
|
||||
await waitFor(async () => {
|
||||
await expect(
|
||||
screen.findByText(`PodId:${itemId}`),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(`PodTitle:${itemId}`),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(`PodId:${itemId2}`),
|
||||
).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText(`PodTitle:${itemId2}`),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call fetchListData with default filters', async () => {
|
||||
await waitFor(() => {
|
||||
expect(fetchListDataMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [filters] = fetchListDataMock.mock.calls[0];
|
||||
expect(filters.limit).toBe(10);
|
||||
expect(filters.offset).toBe(0);
|
||||
expect(filters.filters).toStrictEqual({ items: [], op: 'AND' });
|
||||
expect(filters.groupBy).toBeUndefined();
|
||||
expect(filters.orderBy).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should click to open the row details and update selectedItem in URL', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const firstRowEl = await screen.findByText(`PodId:${itemId}`);
|
||||
await user.click(firstRowEl);
|
||||
|
||||
await waitFor(() => {
|
||||
const selectedItem = onUrlUpdateMock.mock.calls
|
||||
.map((call) => call[0].searchParams.get('selectedItem'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
expect(selectedItem).toBe(`PodId:${itemId}`);
|
||||
});
|
||||
});
|
||||
|
||||
it('should update orderBy in URL when clicking sortable column header', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(`PodId:${itemId}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// TanStackTable renders a sort button with title attribute
|
||||
const sortButton = screen.getByTitle('Id');
|
||||
await user.click(sortButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const lastOrderBy = onUrlUpdateMock.mock.calls
|
||||
.map((call) => call[0].searchParams.get('orderBy'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
|
||||
expect(lastOrderBy).toBeDefined();
|
||||
const parsed = JSON.parse(lastOrderBy as string);
|
||||
expect(parsed.columnName).toBe('id');
|
||||
expect(parsed.order).toBe('asc');
|
||||
});
|
||||
});
|
||||
|
||||
it('should toggle sort order in URL on subsequent header clicks', async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(`PodId:${itemId}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Track orderBy calls
|
||||
const getOrderByCalls = (): string[] =>
|
||||
onUrlUpdateMock.mock.calls
|
||||
.map((call) => call[0].searchParams.get('orderBy'))
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
// First click - should set ascending
|
||||
const sortButton = screen.getByTitle('Id');
|
||||
expect(sortButton).toHaveAttribute('data-sort', 'none');
|
||||
fireEvent.click(sortButton);
|
||||
|
||||
// Wait for URL to show ascending
|
||||
await waitFor(() => {
|
||||
const calls = getOrderByCalls();
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const parsed = JSON.parse(calls[calls.length - 1]);
|
||||
expect(parsed.order).toBe('asc');
|
||||
});
|
||||
|
||||
// Wait for button to have ascending state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('Id')).toHaveAttribute('data-sort', 'ascending');
|
||||
});
|
||||
|
||||
const callsAfterFirstClick = getOrderByCalls().length;
|
||||
|
||||
// Verify only one button exists with title 'Id'
|
||||
const allIdButtons = screen.getAllByTitle('Id');
|
||||
expect(allIdButtons).toHaveLength(1);
|
||||
|
||||
// Second click - should set descending
|
||||
const ascendingButton = screen.getByTitle('Id');
|
||||
expect(ascendingButton).toHaveAttribute('data-sort', 'ascending');
|
||||
fireEvent.click(ascendingButton);
|
||||
|
||||
// Wait for URL to show descending (must be a new call)
|
||||
await waitFor(() => {
|
||||
const calls = getOrderByCalls();
|
||||
expect(calls.length).toBeGreaterThan(callsAfterFirstClick);
|
||||
const parsed = JSON.parse(calls[calls.length - 1]);
|
||||
expect(parsed.order).toBe('desc');
|
||||
});
|
||||
|
||||
// Verify DOM updated
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('Id')).toHaveAttribute('data-sort', 'descending');
|
||||
});
|
||||
});
|
||||
|
||||
it('should update page in URL when clicking pagination', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(`PodId:${itemId}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find pagination navigation and page 2 button
|
||||
const nav = screen.getByRole('navigation');
|
||||
const page2Button = Array.from(nav.querySelectorAll('button')).find(
|
||||
(btn) => btn.textContent?.trim() === '2',
|
||||
);
|
||||
if (!page2Button) {
|
||||
throw new Error('Page 2 button not found in pagination');
|
||||
}
|
||||
await user.click(page2Button);
|
||||
|
||||
await waitFor(() => {
|
||||
const lastPage = onUrlUpdateMock.mock.calls
|
||||
.map((call) => call[0].searchParams.get('page'))
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
|
||||
expect(lastPage).toBe('2');
|
||||
});
|
||||
});
|
||||
|
||||
it('should open row in new tab when ctrl+click on row', async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(`PodId:${itemId}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const firstRow = screen.getByText(`PodId:${itemId}`);
|
||||
// Ctrl+click to open in new tab
|
||||
fireEvent.click(firstRow, { ctrlKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openInNewTabMock).toHaveBeenCalledTimes(1);
|
||||
expect(openInNewTabMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`selectedItem=PodId%3A${itemId}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should open row in new tab when meta+click (cmd on Mac) on row', async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(`PodId:${itemId}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const firstRow = screen.getByText(`PodId:${itemId}`);
|
||||
// Meta+click (cmd on Mac) to open in new tab
|
||||
fireEvent.click(firstRow, { metaKey: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(openInNewTabMock).toHaveBeenCalledTimes(1);
|
||||
expect(openInNewTabMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`selectedItem=PodId%3A${itemId}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with URL params (orderBy, groupBy, pagination)', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
|
||||
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
|
||||
>();
|
||||
const groupByValue = [
|
||||
{ key: 'k8s.namespace.name', dataType: 'string', type: 'resource' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'namespace-default', meta: { 'k8s.namespace.name': 'default' } },
|
||||
],
|
||||
total: 50,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: {
|
||||
orderBy: JSON.stringify({ columnName: 'cpu', order: 'desc' }),
|
||||
groupBy: JSON.stringify(groupByValue),
|
||||
page: '3',
|
||||
},
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should call fetchListData with orderBy/groupBy/offset/limit from URL', async () => {
|
||||
await waitFor(() => {
|
||||
expect(fetchListDataMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const [filters] = fetchListDataMock.mock.calls[0];
|
||||
expect(filters.orderBy).toStrictEqual({ columnName: 'cpu', order: 'desc' });
|
||||
expect(filters.groupBy).toStrictEqual(groupByValue);
|
||||
expect(filters.offset).toBe(20); // (3 - 1) * 10 = 20
|
||||
expect(filters.limit).toBe(10);
|
||||
});
|
||||
|
||||
it('should render expand icons when groupBy is set', async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('namespace-default')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const expandButtons = screen.getAllByRole('button');
|
||||
expect(expandButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should render data with groupBy params', async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('namespace-default')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify the call was made with correct groupBy
|
||||
const callWithGroupBy = fetchListDataMock.mock.calls.find(
|
||||
(c) => c[0].groupBy && c[0].groupBy.length > 0,
|
||||
);
|
||||
expect(callWithGroupBy).toBeDefined();
|
||||
expect(callWithGroupBy?.[0].groupBy).toStrictEqual(groupByValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with empty data', () => {
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
|
||||
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
error: null,
|
||||
rawData: {
|
||||
sentAnyHostMetricsData: true,
|
||||
isSendingK8SAgentMetrics: false,
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should display empty state when no data is returned', async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/This query had no results/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should still call fetchListData', async () => {
|
||||
await waitFor(() => {
|
||||
expect(fetchListDataMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with error response', () => {
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
|
||||
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
error: 'Failed to fetch pods',
|
||||
});
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should call fetchListData even when error occurs', async () => {
|
||||
await waitFor(() => {
|
||||
expect(fetchListDataMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display error message when data.error is set', async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Failed to fetch pods/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with no metrics data (sentAnyHostMetricsData=false)', () => {
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
|
||||
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
error: null,
|
||||
rawData: {
|
||||
sentAnyHostMetricsData: false,
|
||||
isSendingK8SAgentMetrics: false,
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should display no metrics data message', async () => {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/No host metrics data received yet/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display link to documentation', async () => {
|
||||
await waitFor(() => {
|
||||
const link = screen.getByRole('link', { name: /our documentation/i });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
'https://signoz.io/docs/infrastructure-monitoring/hostmetrics/',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with incorrect K8s agent metrics (isSendingK8SAgentMetrics=true)', () => {
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
|
||||
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
error: null,
|
||||
rawData: {
|
||||
sentAnyHostMetricsData: true,
|
||||
isSendingK8SAgentMetrics: true,
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should display upgrade message', async () => {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/upgrade to the latest version of SigNoz k8s-infra/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with end time before retention (endTimeBeforeRetention=true)', () => {
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
|
||||
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [],
|
||||
total: 0,
|
||||
error: null,
|
||||
rawData: {
|
||||
sentAnyHostMetricsData: true,
|
||||
isSendingK8SAgentMetrics: false,
|
||||
endTimeBeforeRetention: true,
|
||||
},
|
||||
});
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should display time range before retention message', async () => {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Queried time range is before earliest K8s metrics/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/please adjust your end time/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('column visibility based on TanStack columns', () => {
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<K8sBaseListProps<TestItemWithName>['fetchListData']>,
|
||||
Parameters<K8sBaseListProps<TestItemWithName>['fetchListData']>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [{ id: 'item-1', name: 'Item 1', desc: 'Description 1' }],
|
||||
total: 1,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should show all columns defined in tableColumns', async () => {
|
||||
renderComponent<TestItemWithName>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumnsWithName(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('item-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// All columns should be visible
|
||||
expect(
|
||||
screen.getByRole('columnheader', { name: /id/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('columnheader', { name: /name/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('column behavior with groupBy (expanded/collapsed)', () => {
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<K8sBaseListProps<TestItemWithGroup>['fetchListData']>,
|
||||
Parameters<K8sBaseListProps<TestItemWithGroup>['fetchListData']>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: 'Item 1',
|
||||
group: 'Group A',
|
||||
meta: { 'k8s.namespace.name': 'default' },
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should show columns when NOT grouped', async () => {
|
||||
renderComponent<TestItemWithGroup>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: {},
|
||||
tableColumns: createTestColumnsWithGroup(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('item-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Columns should be visible
|
||||
expect(
|
||||
screen.getByRole('columnheader', { name: /id/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show columns when grouped', async () => {
|
||||
const groupByValue = [
|
||||
{ key: 'k8s.namespace.name', dataType: 'string', type: 'resource' },
|
||||
];
|
||||
|
||||
renderComponent<TestItemWithGroup>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: {
|
||||
groupBy: JSON.stringify(groupByValue),
|
||||
},
|
||||
tableColumns: createTestColumnsWithGroup(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('item-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Id should be visible
|
||||
expect(
|
||||
screen.getByRole('columnheader', { name: /id/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('column visibility in expanded row (nested table)', () => {
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<K8sBaseListProps<TestItem>['fetchListData']>,
|
||||
Parameters<K8sBaseListProps<TestItem>['fetchListData']>
|
||||
>();
|
||||
const groupByValue = [
|
||||
{ key: 'k8s.namespace.name', dataType: 'string', type: 'resource' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'namespace-default', meta: { 'k8s.namespace.name': 'default' } },
|
||||
],
|
||||
total: 50,
|
||||
error: null,
|
||||
});
|
||||
|
||||
renderComponent<TestItem>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: {
|
||||
groupBy: JSON.stringify(groupByValue),
|
||||
},
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render table with groupBy params and enable expansion', async () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('namespace-default')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify fetch was called with groupBy
|
||||
const callWithGroupBy = fetchListDataMock.mock.calls.find(
|
||||
(c) => c[0].groupBy && c[0].groupBy.length > 0,
|
||||
);
|
||||
expect(callWithGroupBy).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TanStack table column rendering', () => {
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<K8sBaseListProps<TestItemWithName>['fetchListData']>,
|
||||
Parameters<K8sBaseListProps<TestItemWithName>['fetchListData']>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'item-1', name: 'Item 1', desc: 'Description 1' },
|
||||
{ id: 'item-2', name: 'Item 2', desc: 'Description 2' },
|
||||
],
|
||||
total: 2,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render all defined columns', async () => {
|
||||
renderComponent<TestItemWithName>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumnsWithName(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('item-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// All columns should be visible
|
||||
expect(
|
||||
screen.getByRole('columnheader', { name: /id/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('columnheader', { name: /^name$/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render data in cells correctly', async () => {
|
||||
renderComponent<TestItemWithName>({
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
tableColumns: createTestColumnsWithName(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('item-1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('item-2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item 2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
26
frontend/src/container/InfraMonitoringK8sV2/Base/types.ts
Normal file
26
frontend/src/container/InfraMonitoringK8sV2/Base/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { OrderBySchemaType } from '../schemas';
|
||||
|
||||
export type K8sBaseFilters = {
|
||||
filters: TagFilter;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
start: number;
|
||||
end: number;
|
||||
orderBy?: OrderBySchemaType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Type for table row data with required key fields.
|
||||
* Used when rendering raw data in the table.
|
||||
*/
|
||||
export type K8sTableRowData<T> = T & {
|
||||
key: string;
|
||||
id: string;
|
||||
itemKey: string;
|
||||
/** Metadata about which attributes were used for grouping */
|
||||
groupedByMeta?: Record<string, string>;
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface IEntityColumn {
|
||||
label: string;
|
||||
value: string;
|
||||
id: string;
|
||||
defaultVisibility: boolean;
|
||||
canBeHidden: boolean;
|
||||
behavior: 'hidden-on-expand' | 'hidden-on-collapse' | 'always-visible';
|
||||
}
|
||||
|
||||
export interface IInfraMonitoringTableColumnsStore {
|
||||
columns: Record<string, IEntityColumn[]>;
|
||||
columnsHidden: Record<string, string[]>;
|
||||
addColumn: (page: string, columnId: string) => void;
|
||||
removeColumn: (page: string, columnId: string) => void;
|
||||
initializePageColumns: (page: string, columns: IEntityColumn[]) => void;
|
||||
}
|
||||
|
||||
export const useInfraMonitoringTableColumnsStore =
|
||||
create<IInfraMonitoringTableColumnsStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
columns: {},
|
||||
columnsHidden: {},
|
||||
addColumn: (page, columnId): void => {
|
||||
const state = get();
|
||||
const columnDefinition = state.columns[page]?.find(
|
||||
(c) => c.id === columnId,
|
||||
);
|
||||
|
||||
if (!columnDefinition) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!columnDefinition.canBeHidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const columnsHidden = state.columnsHidden[page];
|
||||
|
||||
if (columnsHidden.includes(columnId)) {
|
||||
set({
|
||||
columnsHidden: {
|
||||
...state.columnsHidden,
|
||||
[page]: columnsHidden.filter((id) => id !== columnId),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
removeColumn: (page, columnId): void => {
|
||||
const state = get();
|
||||
const columnDefinition = state.columns[page]?.find(
|
||||
(c) => c.id === columnId,
|
||||
);
|
||||
|
||||
if (!columnDefinition) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!columnDefinition.canBeHidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const columnsHidden = state.columnsHidden[page];
|
||||
|
||||
if (!columnsHidden.includes(columnId)) {
|
||||
set({
|
||||
columnsHidden: {
|
||||
...state.columnsHidden,
|
||||
[page]: [...columnsHidden, columnId],
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
initializePageColumns: (page, columns): void => {
|
||||
const state = get();
|
||||
|
||||
set({
|
||||
columns: {
|
||||
...state.columns,
|
||||
[page]: columns,
|
||||
},
|
||||
});
|
||||
|
||||
if (state.columnsHidden[page] === undefined) {
|
||||
set({
|
||||
columnsHidden: {
|
||||
...state.columnsHidden,
|
||||
[page]: columns
|
||||
.filter((c) => c.defaultVisibility === false)
|
||||
.map((c) => c.id),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: '@signoz/infra-monitoring-columns',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const useInfraMonitoringTableColumnsForPage = (
|
||||
page: string,
|
||||
): [columns: IEntityColumn[], columnsHidden: string[]] => {
|
||||
const state = useInfraMonitoringTableColumnsStore((s) => s.columns);
|
||||
const columnsHidden = useInfraMonitoringTableColumnsStore(
|
||||
(s) => s.columnsHidden,
|
||||
);
|
||||
|
||||
return [state[page] ?? [], columnsHidden[page] ?? []];
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
.itemDataGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.itemDataGroupTagItem {
|
||||
display: block !important;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
100
frontend/src/container/InfraMonitoringK8sV2/Base/utils.tsx
Normal file
100
frontend/src/container/InfraMonitoringK8sV2/Base/utils.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import styles from './utils.module.scss';
|
||||
|
||||
const dotToUnder: Record<string, string> = {
|
||||
'os.type': 'os_type',
|
||||
'host.name': 'host_name',
|
||||
'deployment.environment': 'deployment_environment',
|
||||
'k8s.node.name': 'k8s_node_name',
|
||||
'k8s.cluster.name': 'k8s_cluster_name',
|
||||
'k8s.node.uid': 'k8s_node_uid',
|
||||
'k8s.cronjob.name': 'k8s_cronjob_name',
|
||||
'k8s.daemonset.name': 'k8s_daemonset_name',
|
||||
'k8s.deployment.name': 'k8s_deployment_name',
|
||||
'k8s.job.name': 'k8s_job_name',
|
||||
'k8s.namespace.name': 'k8s_namespace_name',
|
||||
'k8s.pod.name': 'k8s_pod_name',
|
||||
'k8s.pod.uid': 'k8s_pod_uid',
|
||||
'k8s.statefulset.name': 'k8s_statefulset_name',
|
||||
'k8s.persistentvolumeclaim.name': 'k8s_persistentvolumeclaim_name',
|
||||
};
|
||||
|
||||
export function getGroupedByMeta<T extends { meta?: Record<string, string> }>(
|
||||
itemData: T,
|
||||
groupBy: BaseAutocompleteData[],
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
const meta = itemData.meta ?? {};
|
||||
|
||||
groupBy.forEach((group) => {
|
||||
const rawKey = group.key as string;
|
||||
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof meta;
|
||||
result[rawKey] = (meta[metaKey] || meta[rawKey]) ?? '';
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getRowKey<T extends { meta?: Record<string, string> }>(
|
||||
itemData: T,
|
||||
getItemIdentifier: () => string,
|
||||
groupBy: BaseAutocompleteData[],
|
||||
): string {
|
||||
const nodeIdentifier = getItemIdentifier();
|
||||
const meta = itemData.meta ?? {};
|
||||
|
||||
if (groupBy.length === 0) {
|
||||
return nodeIdentifier || JSON.stringify(meta);
|
||||
}
|
||||
|
||||
const groupedMeta = getGroupedByMeta(itemData, groupBy);
|
||||
const groupKey = Object.values(groupedMeta).join('-');
|
||||
|
||||
if (groupKey && nodeIdentifier) {
|
||||
return `${groupKey}-${nodeIdentifier}`;
|
||||
}
|
||||
if (groupKey) {
|
||||
return groupKey;
|
||||
}
|
||||
if (nodeIdentifier) {
|
||||
return nodeIdentifier;
|
||||
}
|
||||
|
||||
return JSON.stringify(meta);
|
||||
}
|
||||
|
||||
export function getGroupByEl<T extends { meta?: Record<string, string> }>(
|
||||
itemData: T,
|
||||
groupBy: IBuilderQuery['groupBy'],
|
||||
): React.ReactNode {
|
||||
const groupByValues: string[] = [];
|
||||
const meta = itemData.meta ?? {};
|
||||
|
||||
groupBy.forEach((group) => {
|
||||
const rawKey = group.key as string;
|
||||
|
||||
// Choose mapped key if present, otherwise use rawKey
|
||||
const metaKey = (dotToUnder[rawKey] ?? rawKey) as keyof typeof meta;
|
||||
const value = meta[metaKey] || meta[rawKey] || '<no-value>';
|
||||
|
||||
groupByValues.push(value);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.itemDataGroup}>
|
||||
{groupByValues.map((value, index) => (
|
||||
<Badge
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
key={`${index}-${value}`}
|
||||
color="secondary"
|
||||
className={styles.itemDataGroupTagItem}
|
||||
>
|
||||
{value === '' ? '<no-value>' : value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from '../Base/K8sBaseList';
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import { getK8sClustersList, K8sClusterData } from './api';
|
||||
import {
|
||||
clusterWidgetInfo,
|
||||
getClusterMetricsQueryPayload,
|
||||
k8sClusterDetailsMetadataConfig,
|
||||
k8sClusterGetEntityName,
|
||||
k8sClusterGetSelectedItemFilters,
|
||||
k8sClusterInitialEventsFilter,
|
||||
k8sClusterInitialLogTracesFilter,
|
||||
} from './constants';
|
||||
import {
|
||||
getK8sClusterItemKey,
|
||||
getK8sClusterRowKey,
|
||||
k8sClustersColumnsConfig,
|
||||
} from './table.config';
|
||||
|
||||
function K8sClustersList({
|
||||
controlListPrefix,
|
||||
}: {
|
||||
controlListPrefix?: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
const { featureFlags } = useAppContext();
|
||||
const dotMetricsEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
|
||||
?.active || false;
|
||||
|
||||
const fetchListData = useCallback(
|
||||
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
|
||||
filters.orderBy ||= {
|
||||
columnName: 'cpu',
|
||||
order: 'desc',
|
||||
};
|
||||
|
||||
const response = await getK8sClustersList(
|
||||
filters,
|
||||
signal,
|
||||
undefined,
|
||||
dotMetricsEnabled,
|
||||
);
|
||||
|
||||
return {
|
||||
data: response.payload?.data.records || [],
|
||||
total: response.payload?.data.total || 0,
|
||||
error: response.error,
|
||||
rawData: response.payload?.data,
|
||||
};
|
||||
},
|
||||
[dotMetricsEnabled],
|
||||
);
|
||||
|
||||
const fetchEntityData = useCallback(
|
||||
async (
|
||||
filters: K8sDetailsFilters,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ data: K8sClusterData | null; error?: string | null }> => {
|
||||
const response = await getK8sClustersList(
|
||||
{
|
||||
filters: filters.filters,
|
||||
start: filters.start,
|
||||
end: filters.end,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
},
|
||||
signal,
|
||||
undefined,
|
||||
dotMetricsEnabled,
|
||||
);
|
||||
|
||||
const records = response.payload?.data.records || [];
|
||||
|
||||
return {
|
||||
data: records.length > 0 ? records[0] : null,
|
||||
error: response.error,
|
||||
};
|
||||
},
|
||||
[dotMetricsEnabled],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<K8sBaseList<K8sClusterData>
|
||||
controlListPrefix={controlListPrefix}
|
||||
entity={InfraMonitoringEntity.CLUSTERS}
|
||||
tableColumns={k8sClustersColumnsConfig}
|
||||
fetchListData={fetchListData}
|
||||
getRowKey={getK8sClusterRowKey}
|
||||
getItemKey={getK8sClusterItemKey}
|
||||
eventCategory={InfraMonitoringEvents.Cluster}
|
||||
/>
|
||||
|
||||
<K8sBaseDetails<K8sClusterData>
|
||||
category={InfraMonitoringEntity.CLUSTERS}
|
||||
eventCategory={InfraMonitoringEvents.Cluster}
|
||||
getSelectedItemFilters={k8sClusterGetSelectedItemFilters}
|
||||
fetchEntityData={fetchEntityData}
|
||||
getEntityName={k8sClusterGetEntityName}
|
||||
getInitialLogTracesFilters={k8sClusterInitialLogTracesFilter}
|
||||
getInitialEventsFilters={k8sClusterInitialEventsFilter}
|
||||
metadataConfig={k8sClusterDetailsMetadataConfig}
|
||||
entityWidgetInfo={clusterWidgetInfo}
|
||||
getEntityQueryPayload={getClusterMetricsQueryPayload}
|
||||
queryKeyPrefix="cluster"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default K8sClustersList;
|
||||
125
frontend/src/container/InfraMonitoringK8sV2/Clusters/api.ts
Normal file
125
frontend/src/container/InfraMonitoringK8sV2/Clusters/api.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { UnderscoreToDotMap } from 'api/utils';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
|
||||
export interface K8sClustersListPayload {
|
||||
filters: TagFilter;
|
||||
groupBy?: BaseAutocompleteData[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
orderBy?: {
|
||||
columnName: string;
|
||||
order: 'asc' | 'desc';
|
||||
};
|
||||
}
|
||||
|
||||
export interface K8sClusterData {
|
||||
clusterUID: string;
|
||||
cpuUsage: number;
|
||||
cpuAllocatable: number;
|
||||
memoryUsage: number;
|
||||
memoryAllocatable: number;
|
||||
meta: {
|
||||
k8s_cluster_name: string;
|
||||
k8s_cluster_uid: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface K8sClustersListResponse {
|
||||
status: string;
|
||||
data: {
|
||||
type: string;
|
||||
records: K8sClusterData[];
|
||||
groups: null;
|
||||
total: number;
|
||||
sentAnyHostMetricsData: boolean;
|
||||
isSendingK8SAgentMetrics: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// TODO(H4ad): Erase this whole file when migrating to openapi
|
||||
export const clustersMetaMap = [
|
||||
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
|
||||
{ dot: 'k8s.cluster.uid', under: 'k8s_cluster_uid' },
|
||||
] as const;
|
||||
|
||||
export function mapClustersMeta(
|
||||
raw: Record<string, unknown>,
|
||||
): K8sClusterData['meta'] {
|
||||
const out: Record<string, unknown> = { ...raw };
|
||||
clustersMetaMap.forEach(({ dot, under }) => {
|
||||
if (dot in raw) {
|
||||
const v = raw[dot];
|
||||
out[under] = typeof v === 'string' ? v : raw[under];
|
||||
}
|
||||
});
|
||||
return out as K8sClusterData['meta'];
|
||||
}
|
||||
|
||||
export const getK8sClustersList = async (
|
||||
props: K8sBaseFilters,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
dotMetricsEnabled = false,
|
||||
): Promise<SuccessResponse<K8sClustersListResponse> | ErrorResponse> => {
|
||||
try {
|
||||
const requestProps = dotMetricsEnabled
|
||||
? {
|
||||
...props,
|
||||
filters: {
|
||||
...props.filters,
|
||||
items: props.filters.items.reduce<typeof props.filters.items>(
|
||||
(acc, item) => {
|
||||
if (item.value === undefined) {
|
||||
return acc;
|
||||
}
|
||||
if (
|
||||
item.key &&
|
||||
typeof item.key === 'object' &&
|
||||
'key' in item.key &&
|
||||
typeof item.key.key === 'string'
|
||||
) {
|
||||
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
|
||||
acc.push({
|
||||
...item,
|
||||
key: { ...item.key, key: mappedKey },
|
||||
});
|
||||
} else {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as typeof props.filters.items,
|
||||
),
|
||||
},
|
||||
}
|
||||
: props;
|
||||
|
||||
const response = await axios.post('/clusters/list', requestProps, {
|
||||
signal,
|
||||
headers,
|
||||
});
|
||||
const payload: K8sClustersListResponse = response.data;
|
||||
|
||||
payload.data.records = payload.data.records.map((record) => ({
|
||||
...record,
|
||||
meta: mapClustersMeta(record.meta as Record<string, unknown>),
|
||||
}));
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: 'Success',
|
||||
payload,
|
||||
params: requestProps,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
1637
frontend/src/container/InfraMonitoringK8sV2/Clusters/constants.ts
Normal file
1637
frontend/src/container/InfraMonitoringK8sV2/Clusters/constants.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
import { Tooltip } from 'antd';
|
||||
import { TableColumnDef } from 'components/TanStackTableView';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
|
||||
|
||||
import EntityGroupHeader from '../Base/EntityGroupHeader';
|
||||
import K8sGroupCell from '../Base/K8sGroupCell';
|
||||
import { formatBytes } from '../commonUtils';
|
||||
import { ValidateColumnValueWrapper } from '../components';
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import { K8sClusterData, K8sClustersListPayload } from './api';
|
||||
import { Boxes } from '@signozhq/icons';
|
||||
|
||||
export function getK8sClusterRowKey(cluster: K8sClusterData): string {
|
||||
return (
|
||||
cluster.clusterUID ||
|
||||
cluster.meta.k8s_cluster_uid ||
|
||||
cluster.meta.k8s_cluster_name
|
||||
);
|
||||
}
|
||||
|
||||
export function getK8sClusterItemKey(cluster: K8sClusterData): string {
|
||||
return cluster.meta.k8s_cluster_name;
|
||||
}
|
||||
|
||||
export const getK8sClustersListQuery = (): K8sClustersListPayload => ({
|
||||
filters: {
|
||||
items: [],
|
||||
op: 'and',
|
||||
},
|
||||
orderBy: { columnName: 'cpu', order: 'desc' },
|
||||
});
|
||||
|
||||
export const k8sClustersColumnsConfig: TableColumnDef<K8sClusterData>[] = [
|
||||
{
|
||||
id: 'clusterGroup',
|
||||
header: (): React.ReactNode => <EntityGroupHeader title="CLUSTER GROUP" />,
|
||||
accessorFn: (row): string => row.meta.k8s_cluster_name || '',
|
||||
width: { min: 300 },
|
||||
enableSort: false,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
visibilityBehavior: 'hidden-on-collapse',
|
||||
cell: ({ isExpanded, toggleExpanded, row }): JSX.Element | null => {
|
||||
return (
|
||||
<ExpandButtonWrapper
|
||||
isExpanded={isExpanded}
|
||||
toggleExpanded={toggleExpanded}
|
||||
>
|
||||
<K8sGroupCell row={row} />
|
||||
</ExpandButtonWrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'clusterName',
|
||||
header: (): React.ReactNode => (
|
||||
<EntityGroupHeader
|
||||
title="Cluster Name"
|
||||
icon={<Boxes data-hide-expanded="true" size={14} />}
|
||||
/>
|
||||
),
|
||||
accessorFn: (row): string => row.meta.k8s_cluster_name || '',
|
||||
width: { min: 290 },
|
||||
enableSort: false,
|
||||
enableRemove: false,
|
||||
enableMove: false,
|
||||
pin: 'left',
|
||||
visibilityBehavior: 'hidden-on-expand',
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const clusterName = value as string;
|
||||
return (
|
||||
<Tooltip title={clusterName}>
|
||||
<TanStackTable.Text>{clusterName}</TanStackTable.Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cpu',
|
||||
header: 'CPU Usage (cores)',
|
||||
accessorFn: (row): number => row.cpuUsage,
|
||||
width: { min: 220 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const cpu = value as number;
|
||||
return (
|
||||
<ValidateColumnValueWrapper
|
||||
value={cpu}
|
||||
entity={InfraMonitoringEntity.CLUSTERS}
|
||||
attribute="CPU metric"
|
||||
>
|
||||
<TanStackTable.Text>{cpu}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cpu_allocatable',
|
||||
header: 'CPU Alloc (cores)',
|
||||
accessorFn: (row): number => row.cpuAllocatable,
|
||||
width: { min: 220 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const cpuAllocatable = value as number;
|
||||
return (
|
||||
<ValidateColumnValueWrapper
|
||||
value={cpuAllocatable}
|
||||
entity={InfraMonitoringEntity.CLUSTERS}
|
||||
attribute="CPU allocatable metric"
|
||||
>
|
||||
<TanStackTable.Text>{cpuAllocatable}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'memory',
|
||||
header: 'Memory Usage (WSS)',
|
||||
accessorFn: (row): number => row.memoryUsage,
|
||||
width: { min: 220 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const memory = value as number;
|
||||
return (
|
||||
<ValidateColumnValueWrapper
|
||||
value={memory}
|
||||
entity={InfraMonitoringEntity.CLUSTERS}
|
||||
attribute="memory metric"
|
||||
>
|
||||
<TanStackTable.Text>{formatBytes(memory)}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'memory_allocatable',
|
||||
header: 'Memory Allocatable',
|
||||
accessorFn: (row): number => row.memoryAllocatable,
|
||||
width: { min: 220 },
|
||||
enableSort: true,
|
||||
cell: ({ value }): React.ReactNode => {
|
||||
const memoryAllocatable = value as number;
|
||||
return (
|
||||
<ValidateColumnValueWrapper
|
||||
value={memoryAllocatable}
|
||||
entity={InfraMonitoringEntity.CLUSTERS}
|
||||
attribute="memory allocatable metric"
|
||||
>
|
||||
<TanStackTable.Text>{formatBytes(memoryAllocatable)}</TanStackTable.Text>
|
||||
</ValidateColumnValueWrapper>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import K8sBaseDetails, { K8sDetailsFilters } from '../Base/K8sBaseDetails';
|
||||
import { K8sBaseList } from '../Base/K8sBaseList';
|
||||
import { K8sBaseFilters } from '../Base/types';
|
||||
import { InfraMonitoringEntity } from '../constants';
|
||||
import { getK8sDaemonSetsList, K8sDaemonSetsData } from './api';
|
||||
import {
|
||||
daemonSetWidgetInfo,
|
||||
getDaemonSetMetricsQueryPayload,
|
||||
k8sDaemonSetDetailsMetadataConfig,
|
||||
k8sDaemonSetGetEntityName,
|
||||
k8sDaemonSetGetSelectedItemFilters,
|
||||
k8sDaemonSetInitialEventsFilter,
|
||||
k8sDaemonSetInitialLogTracesFilter,
|
||||
} from './constants';
|
||||
import {
|
||||
getK8sDaemonSetItemKey,
|
||||
getK8sDaemonSetRowKey,
|
||||
k8sDaemonSetsColumnsConfig,
|
||||
} from './table.config';
|
||||
|
||||
function K8sDaemonSetsList({
|
||||
controlListPrefix,
|
||||
}: {
|
||||
controlListPrefix?: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
const { featureFlags } = useAppContext();
|
||||
const dotMetricsEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
|
||||
?.active || false;
|
||||
|
||||
const fetchListData = useCallback(
|
||||
async (filters: K8sBaseFilters, signal?: AbortSignal) => {
|
||||
filters.orderBy ||= {
|
||||
columnName: 'cpu',
|
||||
order: 'desc',
|
||||
};
|
||||
|
||||
const response = await getK8sDaemonSetsList(
|
||||
filters,
|
||||
signal,
|
||||
undefined,
|
||||
dotMetricsEnabled,
|
||||
);
|
||||
|
||||
return {
|
||||
data: response.payload?.data.records || [],
|
||||
total: response.payload?.data.total || 0,
|
||||
error: response.error,
|
||||
rawData: response.payload?.data,
|
||||
};
|
||||
},
|
||||
[dotMetricsEnabled],
|
||||
);
|
||||
|
||||
const fetchEntityData = useCallback(
|
||||
async (
|
||||
filters: K8sDetailsFilters,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ data: K8sDaemonSetsData | null; error?: string | null }> => {
|
||||
const response = await getK8sDaemonSetsList(
|
||||
{
|
||||
filters: filters.filters,
|
||||
start: filters.start,
|
||||
end: filters.end,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
},
|
||||
signal,
|
||||
undefined,
|
||||
dotMetricsEnabled,
|
||||
);
|
||||
|
||||
const records = response.payload?.data.records || [];
|
||||
|
||||
return {
|
||||
data: records.length > 0 ? records[0] : null,
|
||||
error: response.error,
|
||||
};
|
||||
},
|
||||
[dotMetricsEnabled],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<K8sBaseList<K8sDaemonSetsData>
|
||||
controlListPrefix={controlListPrefix}
|
||||
entity={InfraMonitoringEntity.DAEMONSETS}
|
||||
tableColumns={k8sDaemonSetsColumnsConfig}
|
||||
fetchListData={fetchListData}
|
||||
getRowKey={getK8sDaemonSetRowKey}
|
||||
getItemKey={getK8sDaemonSetItemKey}
|
||||
eventCategory={InfraMonitoringEvents.DaemonSet}
|
||||
/>
|
||||
|
||||
<K8sBaseDetails<K8sDaemonSetsData>
|
||||
category={InfraMonitoringEntity.DAEMONSETS}
|
||||
eventCategory={InfraMonitoringEvents.DaemonSet}
|
||||
getSelectedItemFilters={k8sDaemonSetGetSelectedItemFilters}
|
||||
fetchEntityData={fetchEntityData}
|
||||
getEntityName={k8sDaemonSetGetEntityName}
|
||||
getInitialLogTracesFilters={k8sDaemonSetInitialLogTracesFilter}
|
||||
getInitialEventsFilters={k8sDaemonSetInitialEventsFilter}
|
||||
metadataConfig={k8sDaemonSetDetailsMetadataConfig}
|
||||
entityWidgetInfo={daemonSetWidgetInfo}
|
||||
getEntityQueryPayload={getDaemonSetMetricsQueryPayload}
|
||||
queryKeyPrefix="daemonset"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default K8sDaemonSetsList;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user