Compare commits

...

7 Commits

Author SHA1 Message Date
Vinicius Lourenço
5d2e6d12e4 chore(storybook): add more stories for specific pages/components (#13001)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

This PR adds a few more stories for:

- track query builder (v5 and old v3/v4)
- track few missing states of dashboard/planned downtime/ai
observability

I'm using these stories to help me track the changes to adopt the new
version of our component library.

#### Issues closed by this PR

Related to https://github.com/SigNoz/engineering-pod/issues/6157

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

A few stories as example:

| Header |
|--------|
| <img width="1680" height="1329" alt="image"
src="https://github.com/user-attachments/assets/15fa2e9b-d3aa-4bcf-8357-1e196220c33a"
/> |
| <img width="1680" height="1329" alt="image"
src="https://github.com/user-attachments/assets/89ccc4b2-8b2c-4c51-9402-e0899dc99a09"
/> |
| <img width="1680" height="1731" alt="image"
src="https://github.com/user-attachments/assets/cd876b83-2472-4d05-ba19-4e67841cbb0e"
/> |
| <img width="1680" height="1357" alt="image"
src="https://github.com/user-attachments/assets/8fddf06c-060a-4e44-b340-5102d9020f5d"
/> |
| <img width="1680" height="1329" alt="image"
src="https://github.com/user-attachments/assets/0755b400-92eb-4304-8125-dfc62f3b564d"
/> |
2026-10-12 01:22:40 +00:00
Nikhil Soni
6b13794728 feat(promote): support indexes on trace attributes and remove prefix requirement in paths (#13021)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Stacked on #12839.

## What this does

**Per-path indexes for trace attributes**
The `traces/attribute` domain now supports `indexes`, same as logs body.
Promoted attributes are indexed on `attributes_promoted`, unpromoted
ones on `attributes` — so indexing without promoting works, like it does
for logs body.

Trace index expressions are plain sub-column casts, e.g.
`attributes_promoted.http.route::String`.

**Bare paths in every domain (breaking)**
The `body.` prefix is dropped — the API URL already names the context,
so paths are bare attribute names everywhere: `body.user.name` →
`user.name`. Existing promotions stay valid (paths were always stored
bare). Prefixed paths (`body.`, `attributes.`, …) are rejected with a
guiding error.
2026-10-09 15:05:01 +00:00
Nikhil Soni
ba3ca5645e feat(promote): refactor path promotion API to support traces as well (#12839)
## Summary

- Refactors the promote module into a target-parameterized core
(`promotetypes.Target`) so the existing logs `body_v2 → body_promoted`
flow and new promotion domains share one implementation.
- Adds the spans `attributes → attributes_promoted` JSON domain behind
new endpoints `POST/GET /api/v1/traces/promote_paths`. Promotions are
recorded as
`('traces','attributes_promoted','JSON()','attribute',<name>,0,now)`
rows in `signoz_metadata.column_evolution_metadata` — the same rows the
traces field mapper already consumes to read
`attributes_promoted.<path>` per time window, so queries pick up
promoted attributes automatically (forward-only, with the usual
`multiIf` fan-out across the promotion boundary).

🤖 Generated with Kimi K3

Note: Will raise a followup for supporting indexing on trace attribute,
wanted to keep the diff clean this is more of a refactor
2026-10-09 13:05:56 +00:00
Ashwin Bhatkal
3d22976c72 feat(infra-monitoring): filter k8s lists by pod status, node readiness and container status (#12946)
#### Description

All kubernetes resource views now have support for filtering on
statuses.
- Pod status can be filtered everywhere except in Containers
- Container status can be filtered only in containers
- Node status can filtered in Nodes and Clusters

#### Screen Recording



https://github.com/user-attachments/assets/4820f0c3-be4b-4a13-9826-c1b5240b2141



#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/531
Closes https://github.com/SigNoz/pulse-pod/issues/528
2026-10-09 11:33:47 +00:00
Gaurav Tewari
059801d80d feat(trace-details): read header metadata from the trace summary API (#13065)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Trace details header reads its metadata from `GET
/api/v1/traces/{id}/summary` (#12911) instead of deriving it from the
waterfall response.



<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

close -
https://github.com/orgs/SigNoz/projects/59/views/18?pane=issue&itemId=263423245&issue=SigNoz%7Cnerve-pod%7C396

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/b5900a84-20b5-402a-bf3f-f137cac3c2a3




<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information



- Stack 1/4.

this is just a simple change. we will be doing ai realted change in
upcomming prs
- Also for now the error state is not their. we keep showing loading
just like span details panel (lmk if we can change this in follow up )

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-10-09 11:11:08 +00:00
praneeth-signoz
c44926b5f8 feat(alert-channel-integrations): integration tests using wiremock for google chat, incident.io, jira and jsm ops- #12435 (#12985)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

- Adds WireMock-backed integration tests for four alert channels: Google
Chat, incident.io, Jira, and JSM Ops.
- Each channel gets two suites: the synchronous channel-test API
(tests/alerts/) and the real firing-rule pipeline (tests/alertmanager/).
- The shared WireMock container serves real TLS (CA from
https://github.com/SigNoz/signoz/pull/12486) under network aliases for
signoz-test.atlassian.net, and api.atlassian.com. A tls-hosts container
label recreates reused containers when the host list changes.
- Google Chat covers the cardsV2 payload with deep links, verbatim
webhook url, permanent 400/403, and a 429→200 retry.
- incident.io covers the alert event shape, a single Bearer prefix on
the token, permanent 401/422, and a 429→202 retry.
- Jira covers issue create (JQL + basic auth + ADF panel), the wont-fix
JQL clause, update of an existing open issue, reopen of a done issue,
permanent 400/401, service-account routing via the api.atlassian.com
gateway, and a search 429 retry.
- JSM Ops covers alert create plus a timeline note (GenieKey auth, HTML
body), the drop-failed-note tolerance, permanent 422/401, and a create
429 retry.
- The shared webhook-verify helper gains urlPath/count/min_count
matching and a path_pattern regex variant for paths embedding the
group-hash alias.
- Fixes a harness bug: ClickHouse mounted its cluster config from pytest
basetemp, which is wiped every session, so reused containers
hot-reloaded away their cluster definition. The configs now live in the
cross-session cache.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Closes https://github.com/SigNoz/pulse-pod/issues/165
Closes https://github.com/SigNoz/pulse-pod/issues/373


<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

- Channel-test readiness is polled (channels/test returns 404 until the
org's alertmanager registers) instead of a fixed sleep.
- Resolved-path delivery (send_resolved close/transition flows) is
deliberately out of scope: it needs a real alert to stop firing, which
is timing-heavy; the status mapping is unit-tested.

<!--Please delete paragraphs that you did not use before submitting.-->

---------

Co-authored-by: nikhilmantri0902 <nikhil.mantri1999@gmail.com>
Co-authored-by: Naman Verma <naman.verma@signoz.io>
Co-authored-by: Praneeth Lingam <praneethlingam@Ollys-MacBook-Pro.local>
2026-10-09 10:39:53 +00:00
Aditya Singh
5f8fa52e7c feat(saved-views): views header in the quick filters sidebar (#13011)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description
- real saved views header on top of the sidebar layout.. my view,
selected view and unsaved changes states per figma. create and save as
new share one modal, ✓ updates the view or saves it as new, ✕ discards
back to the saved view and the undo arrow clears it. clear only removes
what the view set, time range stays.
- unsaved changes check compares the queries in their stored v5 form
with empty values dropped like the server does.. comparing after mapping
back to the qb shape made every untouched view read dirty. no amber
flash while switching views or ticking quick filters.
- last used view reopens when the explorer is opened with nothing in the
url, decided on first render.. the default query check the bar uses
never passes, the provider fills `aggregateOperator`.
- logs and traces take the tab from the url, and logs ends live mode
whenever the tab is not the list.
- quick filters header restyled to match the views header.. ghost
buttons, design system tooltips, no funnel, same row height. shared
component so every qf page gets it.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/71

#### Screenshots


https://github.com/user-attachments/assets/982f87ab-185c-4321-8056-310131e6cb98



#### Additional Information
- behind `SAVED_VIEW_ENABLED`, flag off is today's ui.
- manual pass on logs and traces, metrics smoke. restore on logs and the
settings tooltip not rechecked after the last fixes.
- old bar still has its own restore bug, it goes away with the bar.
2026-10-09 07:52:18 +00:00
227 changed files with 15259 additions and 1234 deletions

View File

@@ -7756,6 +7756,8 @@ components:
type: object
PromotetypesPromotePath:
properties:
context:
type: string
indexes:
items:
$ref: '#/components/schemas/PromotetypesWrappedIndex'
@@ -7764,6 +7766,12 @@ components:
type: string
promote:
type: boolean
signal:
type: string
required:
- signal
- context
- path
type: object
PromotetypesWrappedIndex:
properties:
@@ -13432,110 +13440,6 @@ paths:
tags:
- llmpricingrules
x-signoz-stability: alpha
/api/v1/logs/promote_paths:
get:
deprecated: false
description: This endpoints promotes and indexes paths
operationId: ListPromotedAndIndexedPaths
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Promote and index paths
tags:
- logs
x-signoz-stability: alpha
post:
deprecated: false
description: This endpoints promotes and indexes paths
operationId: HandlePromoteAndIndexPaths
requestBody:
content:
application/json:
schema:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
responses:
"201":
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Promote and index paths
tags:
- logs
x-signoz-stability: alpha
/api/v1/org/preferences:
get:
deprecated: false
@@ -13707,6 +13611,152 @@ paths:
tags:
- preferences
x-signoz-stability: alpha
/api/v1/promoted_paths:
get:
deprecated: false
description: This endpoint lists the promoted paths of every JSON column, each
annotated with its signal and context. The signal, context, promoted and indexes
query parameters filter the listing.
operationId: ListPromotedPaths
parameters:
- in: query
name: signal
schema:
type: string
- in: query
name: context
schema:
type: string
- in: query
name: promoted
schema:
nullable: true
type: boolean
- in: query
name: indexes
schema:
nullable: true
type: boolean
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List promoted paths
tags:
- promote
x-signoz-stability: alpha
post:
deprecated: false
description: This endpoint promotes paths of JSON columns to their promoted
columns. Each path names its promotion target with its signal and context,
e.g. traces/attribute.
operationId: PromotePaths
requestBody:
content:
application/json:
examples:
logs_body:
summary: 'Logs body: promote a path and index it'
value:
- context: body
indexes:
- fieldDataType: string
granularity: 1
type: ngrambf_v1(4, 1024, 2, 0)
path: user.name
promote: true
signal: logs
traces_attribute:
summary: 'Traces attribute: promote a path'
value:
- context: attribute
path: http.method
promote: true
signal: traces
schema:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
responses:
"201":
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Promote paths
tags:
- promote
x-signoz-stability: alpha
/api/v1/roles:
get:
deprecated: false

View File

@@ -43,6 +43,11 @@ const mockAliases = [
find: /^@signozhq\/ui\/tooltip$/,
replacement: `${srcPath}/storybook/mocks/tooltip.mock.tsx`,
},
{
// jest: not replaced, a test asserts on the toast it fires.
find: /^@signozhq\/ui\/sonner$/,
replacement: `${srcPath}/storybook/mocks/sonner.mock.tsx`,
},
];
/**

View File

@@ -70,3 +70,49 @@
Object.defineProperty(window, 'Date', { value: FrozenDate, writable: true });
})();
</script>
<script>
// Storybook has no microphone. Chromium's recognizer asks for one and for
// Google's speech service, and the `not-allowed` or `network` it reports makes
// the assistant hide its mic for the rest of the tab. This one starts
// listening at once and hears only what a story passes to `hear`.
(() => {
class StorySpeechRecognition extends EventTarget {
static listening = null;
lang = 'en-US';
continuous = false;
interimResults = false;
onstart = null;
onresult = null;
onerror = null;
onend = null;
start() {
StorySpeechRecognition.listening = this;
setTimeout(() => this.onstart?.());
}
stop() {
if (StorySpeechRecognition.listening === this) {
StorySpeechRecognition.listening = null;
}
setTimeout(() => this.onend?.());
}
abort() {
this.stop();
}
/** An interim result: the words so far, still listening. */
hear(transcript) {
const result = Object.assign([{ transcript, confidence: 1 }], {
isFinal: false,
});
this.onresult?.({ resultIndex: 0, results: [result] });
}
}
window.SpeechRecognition = StorySpeechRecognition;
window.webkitSpeechRecognition = StorySpeechRecognition;
})();
</script>

View File

@@ -122,7 +122,21 @@ const preview: Preview = {
['List', 'New', 'Edit'],
],
'Dashboards',
['List', 'Detail', 'Panel Editor', 'Public'],
[
'List',
'Detail',
[
'Overview',
'Settings',
'Variables',
'New Variable',
'Edit Variable',
'Authz',
],
'Panel Editor',
['Overview', 'Chart Options', 'Thresholds and Links'],
'Public',
],
'Services',
['List', 'Detail', 'Top Level Operations', 'Service Map'],
'Logs',

View File

@@ -23,6 +23,13 @@ const IGNORED_MESSAGES = [
// (YouTube embeds, the docs pane) so they hit the real network instead of
// an unanswered msw request; the block is the point, not a bug.
/violates the following Content Security Policy directive/,
// The filter editor's ANTLR parser reports every syntax error through
// `console.error` (`line 1:14 missing ...`), so each partial expression
// typed into it logs one; the editor shows the same errors on screen.
/^line \d+:\d+ /,
// Chromium's echo of a 4xx/5xx response, which the `[response]` entry below
// already reports with its URL and the time its request left.
/^Failed to load resource: the server responded with a status of /,
];
interface CapturedMessage {
@@ -86,7 +93,9 @@ const config: TestRunnerConfig = {
page.on('response', (response) => {
if (response.status() >= 400) {
messages.push({
at: Date.now(),
// The browser's clock, when the request left: a busy runner receives
// the outgoing story's responses after the next story has started.
at: response.request().timing().startTime,
text: `[response] ${response.status()} ${response.url()}`,
});
}

View File

@@ -4,23 +4,15 @@
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import { useMutation } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
HandleExportRawDataPOSTParams,
ListPromotedAndIndexedPaths200,
PromotetypesPromotePathDTO,
Querybuildertypesv5QueryRangeRequestDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
@@ -28,26 +20,6 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoints allows complex query exporting raw data for traces and logs
* @summary Export raw data
@@ -149,175 +121,3 @@ export const useHandleExportRawDataPOST = <
> => {
return useMutation(getHandleExportRawDataPOSTMutationOptions(options));
};
/**
* This endpoints promotes and indexes paths
* @summary Promote and index paths
*/
export const listPromotedAndIndexedPaths = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListPromotedAndIndexedPaths200>({
url: `/api/v1/logs/promote_paths`,
method: 'GET',
signal,
});
};
export const getListPromotedAndIndexedPathsQueryKey = () => {
return [`/api/v1/logs/promote_paths`] as const;
};
export const getListPromotedAndIndexedPathsQueryOptions = <
TData = Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListPromotedAndIndexedPathsQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>
> = ({ signal }) => listPromotedAndIndexedPaths(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListPromotedAndIndexedPathsQueryResult = NonNullable<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>
>;
export type ListPromotedAndIndexedPathsQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Promote and index paths
*/
export function useListPromotedAndIndexedPaths<
TData = Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedAndIndexedPaths>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListPromotedAndIndexedPathsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary Promote and index paths
*/
export const invalidateListPromotedAndIndexedPaths = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListPromotedAndIndexedPathsQueryKey() },
options,
);
return queryClient;
};
/**
* This endpoints promotes and indexes paths
* @summary Promote and index paths
*/
export const handlePromoteAndIndexPaths = (
promotetypesPromotePathDTONull?: BodyType<
PromotetypesPromotePathDTO[] | null
> | null,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/logs/promote_paths`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: promotetypesPromotePathDTONull,
signal,
});
};
export const getHandlePromoteAndIndexPathsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
> => {
const mutationKey = ['handlePromoteAndIndexPaths'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> }
> = (props) => {
const { data } = props ?? {};
return handlePromoteAndIndexPaths(data);
};
return { mutationFn, ...mutationOptions };
};
export type HandlePromoteAndIndexPathsMutationResult = NonNullable<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>
>;
export type HandlePromoteAndIndexPathsMutationBody =
| BodyType<PromotetypesPromotePathDTO[] | null>
| undefined;
export type HandlePromoteAndIndexPathsMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Promote and index paths
*/
export const useHandlePromoteAndIndexPaths = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof handlePromoteAndIndexPaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
> => {
return useMutation(getHandlePromoteAndIndexPathsMutationOptions(options));
};

View File

@@ -0,0 +1,232 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'pnpm generate:api'
* SigNoz
*/
import { useMutation, useQuery } from 'react-query';
import type {
InvalidateOptions,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import type {
ListPromotedPaths200,
ListPromotedPathsParams,
PromotetypesPromotePathDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
const withQueryKey = <T extends object, K>(
query: T,
queryKey: K,
): T & { queryKey: K } => {
const result = { queryKey } as T & { queryKey: K };
for (const key of Object.keys(query)) {
// The explicit queryKey always wins, matching the previous
// `{ ...query, queryKey }` spread where it was set last.
if (key === 'queryKey') {
continue;
}
Object.defineProperty(result, key, {
enumerable: true,
configurable: true,
get: () => (query as Record<string, unknown>)[key],
});
}
return result;
};
/**
* This endpoint lists the promoted paths of every JSON column, each annotated with its signal and context. The signal, context, promoted and indexes query parameters filter the listing.
* @summary List promoted paths
*/
export const listPromotedPaths = (
params?: ListPromotedPathsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListPromotedPaths200>({
url: `/api/v1/promoted_paths`,
method: 'GET',
params,
signal,
});
};
export const getListPromotedPathsQueryKey = (
params?: ListPromotedPathsParams,
) => {
return [`/api/v1/promoted_paths`, ...(params ? [params] : [])] as const;
};
export const getListPromotedPathsQueryOptions = <
TData = Awaited<ReturnType<typeof listPromotedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListPromotedPathsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedPaths>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListPromotedPathsQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listPromotedPaths>>
> = ({ signal }) => listPromotedPaths(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listPromotedPaths>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListPromotedPathsQueryResult = NonNullable<
Awaited<ReturnType<typeof listPromotedPaths>>
>;
export type ListPromotedPathsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List promoted paths
*/
export function useListPromotedPaths<
TData = Awaited<ReturnType<typeof listPromotedPaths>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListPromotedPathsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listPromotedPaths>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListPromotedPathsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return withQueryKey(query, queryOptions.queryKey);
}
/**
* @summary List promoted paths
*/
export const invalidateListPromotedPaths = async (
queryClient: QueryClient,
params?: ListPromotedPathsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListPromotedPathsQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint promotes paths of JSON columns to their promoted columns. Each path names its promotion target with its signal and context, e.g. traces/attribute.
* @summary Promote paths
*/
export const promotePaths = (
promotetypesPromotePathDTONull?: BodyType<
PromotetypesPromotePathDTO[] | null
> | null,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/promoted_paths`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: promotetypesPromotePathDTONull,
signal,
});
};
export const getPromotePathsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
> => {
const mutationKey = ['promotePaths'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof promotePaths>>,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> }
> = (props) => {
const { data } = props ?? {};
return promotePaths(data);
};
return { mutationFn, ...mutationOptions };
};
export type PromotePathsMutationResult = NonNullable<
Awaited<ReturnType<typeof promotePaths>>
>;
export type PromotePathsMutationBody =
| BodyType<PromotetypesPromotePathDTO[] | null>
| undefined;
export type PromotePathsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Promote paths
*/
export const usePromotePaths = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof promotePaths>>,
TError,
{ data?: BodyType<PromotetypesPromotePathDTO[] | null> },
TContext
> => {
return useMutation(getPromotePathsMutationOptions(options));
};

View File

@@ -9519,6 +9519,10 @@ export interface PromotetypesWrappedIndexDTO {
}
export interface PromotetypesPromotePathDTO {
/**
* @type string
*/
context: string;
/**
* @type array
*/
@@ -9526,11 +9530,15 @@ export interface PromotetypesPromotePathDTO {
/**
* @type string
*/
path?: string;
path: string;
/**
* @type boolean
*/
promote?: boolean;
/**
* @type string
*/
signal: string;
}
export interface Querybuildertypesv5AggregationMetaDTO {
@@ -12834,17 +12842,6 @@ export type ListUnmappedLLMModels200 = {
status: string;
};
export type ListPromotedAndIndexedPaths200 = {
/**
* @type array,null
*/
data: PromotetypesPromotePathDTO[] | null;
/**
* @type string
*/
status: string;
};
export type ListOrgPreferences200 = {
/**
* @type array
@@ -12870,6 +12867,40 @@ export type GetOrgPreference200 = {
export type UpdateOrgPreferencePathParameters = {
name: string;
};
export type ListPromotedPathsParams = {
/**
* @type string
* @description undefined
*/
signal?: string;
/**
* @type string
* @description undefined
*/
context?: string;
/**
* @type boolean,null
* @description undefined
*/
promoted?: boolean | null;
/**
* @type boolean,null
* @description undefined
*/
indexes?: boolean | null;
};
export type ListPromotedPaths200 = {
/**
* @type array,null
*/
data: PromotetypesPromotePathDTO[] | null;
/**
* @type string
*/
status: string;
};
export type ListRoles200 = {
/**
* @type array

View File

@@ -0,0 +1,59 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import LabelColumn from '../LabelColumn';
const meta = {
title: 'Components/Label Column',
component: LabelColumn,
tags: ['play'],
decorators: [withCanvas({ maxWidth: 180 })],
args: {
labels: ['severity', 'team', 'service', 'environment'],
value: {
severity: 'critical',
team: 'payments',
service: 'checkout',
environment: 'production',
},
},
} satisfies Meta<typeof LabelColumn>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Labels that fit the column; the rest collapse into a `+N` chip. */
export const Default: Story = {};
/** Copying one label from its tooltip. */
export const LabelCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.hover(
await within(canvasElement).findByTestId('label-tag-severity'),
);
await userEvent.click(
await screen.findByRole('button', { name: 'Copy to clipboard' }),
);
await waitFor(() =>
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
);
},
};
/** Copying the labels that overflow the column from the `+N` chip's tooltip. */
export const OverflowLabelsCopiedToast: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.hover(
await within(canvasElement).findByTestId('label-overflow-badge'),
);
await userEvent.click(
await screen.findByRole('button', { name: 'Copy to clipboard' }),
);
await waitFor(() =>
expect(screen.getByText(/copied! use in search/i)).toBeVisible(),
);
},
};

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent } from 'storybook/test';
import { expect, screen, userEvent, waitFor } from 'storybook/test';
import { DataSource } from 'types/common/queryBuilder';
import FieldsSelector from '../FieldsSelector';
@@ -56,6 +56,21 @@ export const UnsavedChanges: Story = {
},
};
/** Saving: the toast raised once the edited columns are applied. */
export const SavedToast: Story = {
play: async (): Promise<void> => {
const [addField] = await screen.findAllByRole('button', { name: 'Add' });
await userEvent.click(addField);
await userEvent.click(
await screen.findByRole('button', { name: 'Save changes' }),
);
await waitFor(() =>
expect(screen.getByText('Saved successfully')).toBeVisible(),
);
},
};
/** Empty: the suggestion request succeeds with no columns to add. */
export const NoResults: Story = {
parameters: {

View File

@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent } from 'storybook/test';
import HeaderRightSection from '../HeaderRightSection';
const meta = {
title: 'Components/Header Right Section',
component: HeaderRightSection,
tags: ['play'],
args: {
enableFeedback: true,
enableShare: true,
enableAnnouncements: false,
},
} satisfies Meta<typeof HeaderRightSection>;
export default meta;
type Story = StoryObj<typeof meta>;
/** The top bar's actions: feedback and share. */
export const Default: Story = {};
/** A feedback note submitted from the popover, which toasts once it is sent. */
export const FeedbackSubmittedToast: Story = {
play: async (): Promise<void> => {
await userEvent.click(
await screen.findByRole('button', { name: 'Feedback' }),
);
await userEvent.type(
await screen.findByPlaceholderText(/write your feedback here/i),
'The new explorer is great',
);
await userEvent.click(await screen.findByRole('button', { name: /submit/i }));
await screen.findByText(/feedback submitted successfully/i);
},
};

View File

@@ -36,6 +36,7 @@ import {
filterOptionsBySearch,
findOptionLabelText,
handleScrollToBottom,
findOptionLabel,
prioritizeOrAddOptionForMultiSelect,
SPACEKEY,
} from './utils';
@@ -73,6 +74,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
onDropdownVisibleChange,
showIncompleteDataMessage = false,
showLabels = false,
preserveOptionOrder = false,
enableRegexOption = false,
isDynamicVariable = false,
showRetryButton = true,
@@ -311,7 +313,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
]);
} else {
setVisibleOptions(
selectedValues.length > 0 && isEmpty(searchText)
selectedValues.length > 0 && isEmpty(searchText) && !preserveOptionOrder
? prioritizeOrAddOptionForMultiSelect(filteredOptions, selectedValues)
: filteredOptions,
);
@@ -1475,6 +1477,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const shouldPrioritize =
selectedValues.length > 0 &&
isEmpty(searchText) &&
!preserveOptionOrder &&
!(hasSections && (allOptionShown || isAllSelected));
const processedOptions = shouldPrioritize
@@ -1805,6 +1808,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
isDynamicVariable,
showRetryButton,
waitingMessage,
preserveOptionOrder,
]);
// Custom handler for dropdown visibility changes
@@ -1892,7 +1896,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
const { label: labelProp, value, closable, onClose } = props;
const label = showLabels
? options.find((option) => option.value === value)?.label || labelProp
? findOptionLabel(options, value) || labelProp
: labelProp;
// If the display value is the special ALL value, render the ALL tag

View File

@@ -0,0 +1,70 @@
import { findOptionLabel } from '../utils';
describe('findOptionLabel', () => {
const sectioned = [
{
label: 'Lifecycle',
options: [
{ label: 'Running', value: 'running' },
{ label: 'Pending', value: 'pending' },
],
},
{
label: 'Error status',
options: [{ label: 'CrashLoopBackOff', value: 'crashloopbackoff' }],
},
];
it('resolves a label nested inside a section', () => {
expect(findOptionLabel(sectioned, 'crashloopbackoff')).toBe(
'CrashLoopBackOff',
);
});
it('resolves a label on a flat option list', () => {
expect(findOptionLabel([{ label: 'Ready', value: 'ready' }], 'ready')).toBe(
'Ready',
);
});
it('returns undefined for a value no option declares', () => {
expect(findOptionLabel(sectioned, 'nope')).toBeUndefined();
});
});
describe('findOptionLabel for overflow tags', () => {
// The `+N` tooltip and the visible tag both resolve labels this way, so a
// sectioned list must not fall back to the raw enum value in either.
it('resolves every value in a sectioned list', () => {
const sections = [
{
label: 'Lifecycle',
options: [
{ label: 'Running', value: 'running' },
{ label: 'ContainerCreating', value: 'containercreating' },
],
},
{
label: 'Error status',
options: [
{ label: 'OOMKilled', value: 'oomkilled' },
{ label: 'NodeAffinity', value: 'nodeaffinity' },
],
},
];
const resolved = [
'running',
'containercreating',
'oomkilled',
'nodeaffinity',
].map((value) => findOptionLabel(sections, value) ?? value);
expect(resolved).toStrictEqual([
'Running',
'ContainerCreating',
'OOMKilled',
'NodeAffinity',
]);
});
});

View File

@@ -66,6 +66,7 @@ export interface CustomMultiSelectProps extends Omit<
maxTagTextLength?: number;
showIncompleteDataMessage?: boolean;
showLabels?: boolean;
preserveOptionOrder?: boolean;
enableRegexOption?: boolean;
isDynamicVariable?: boolean;
showRetryButton?: boolean;

View File

@@ -69,6 +69,28 @@ export const prioritizeOrAddOptionForSingleSelect = (
return [foundOption, ...filteredOptions];
};
/**
* Options may be grouped into sections, so resolving a value's label has to look
* one level down as well — a sectioned list has no label at the top level.
*/
export const findOptionLabel = (
options: OptionData[],
value: string,
): string | undefined => {
for (const option of options) {
if (option.value === value) {
return option.label;
}
const nested = option.options?.find((subOption) => subOption.value === value);
if (nested) {
return nested.label;
}
}
return undefined;
};
export const prioritizeOrAddOptionForMultiSelect = (
options: OptionData[],
values: string[], // Only supports multiple values (string[])

View File

@@ -0,0 +1,7 @@
.overflowValues {
max-width: 360px;
margin: 0;
padding-left: 14px;
list-style: disc outside;
overflow-wrap: anywhere;
}

View File

@@ -3,7 +3,7 @@ import TooltipScrollArea, {
TOOLTIP_SCROLL_CONTENT_CLASS,
} from 'components/TooltipScrollArea/TooltipScrollArea';
import styles from '../../VariablesBar.module.scss';
import styles from './OverflowValuesTooltip.module.scss';
interface OverflowValuesTooltipProps {
/** The selected values the pill hides behind this `+N`. */

View File

@@ -0,0 +1,229 @@
import { EditorView } from '@codemirror/view';
import { userEvent, waitFor, within } from 'storybook/test';
/** Suggestions wait on a 300ms debounce and a fetch, past the 1s default. */
const untilLoaded = { timeout: 15_000 };
/** Throws until `found` holds something, which is what `waitFor` retries on. */
const present = <TValue>(
found: TValue | null | undefined,
what: string,
): TValue => {
if (found === null || found === undefined) {
throw new Error(`${what} not found`);
}
return found;
};
const suggestionList = (canvasElement: HTMLElement): HTMLElement | null =>
canvasElement.querySelector<HTMLElement>('.cm-tooltip-autocomplete');
const suggestionRow = (
canvasElement: HTMLElement,
text: string,
): HTMLElement | undefined => {
const list = suggestionList(canvasElement);
return list
? within(list)
.queryAllByRole('option')
.find((option) => option.textContent?.includes(text))
: undefined;
};
/** Ctrl+Space, the editor's own shortcut for asking for suggestions. */
const requestSuggestions = (editor: HTMLElement): void => {
editor.dispatchEvent(
new KeyboardEvent('keydown', {
key: ' ',
code: 'Space',
ctrlKey: true,
bubbles: true,
}),
);
};
const currentView = (
canvasElement: HTMLElement,
): { editor: HTMLElement; view: EditorView } => {
// An explorer renders one editor per query; the first is the one on screen.
const editor = present(
canvasElement.querySelector<HTMLElement>(
'.code-mirror-where-clause .cm-content',
),
'filter editor',
);
return {
editor,
view: present(EditorView.findFromDOM(editor), 'editor view'),
};
};
/**
* The explorers wrap the filter in `OverlayScrollbar`, which initialises when
* the browser is idle. Initialising moves the content, the editor with it, and
* focuses the editor again through the DOM, which puts the caret back at the
* start and swaps the suggestions for the key list. Throws until every wrapper
* around the filter has initialised.
*/
const assertScrollbarsReady = (editor: HTMLElement): void => {
for (
let wrapper = editor.closest('.overlay-scrollbar');
wrapper;
wrapper = wrapper.parentElement?.closest('.overlay-scrollbar') ?? null
) {
if (!wrapper.hasAttribute('data-overlayscrollbars')) {
throw new Error('scrollbars around the filter still initialising');
}
}
};
/**
* Waits until `text` shows in the suggestion list, asking for suggestions
* whenever the list is shut. Focus and typing only open it once the keys have
* loaded, and moving the caret never does.
*/
const waitForSuggestion = (
canvasElement: HTMLElement,
text: string,
): Promise<HTMLElement> =>
waitFor(
() => {
const { editor } = currentView(canvasElement);
if (!suggestionList(canvasElement)) {
requestSuggestions(editor);
}
return present(suggestionRow(canvasElement, text), `suggestion "${text}"`);
},
{ ...untilLoaded, interval: 250 },
);
/**
* Focuses the filter once the scrollbars around it have initialised, and waits
* for its suggestion list.
*/
const focusFilter = async (canvasElement: HTMLElement): Promise<EditorView> => {
await waitFor(
() => {
const { editor, view } = currentView(canvasElement);
assertScrollbarsReady(editor);
if (!view.hasFocus) {
view.focus();
}
if (!suggestionList(canvasElement)) {
requestSuggestions(editor);
}
return present(suggestionList(canvasElement), 'suggestion list');
},
{ ...untilLoaded, interval: 250 },
);
return currentView(canvasElement).view;
};
/** Waits for a row of the suggestion list. */
export const findSuggestion = (
canvasElement: HTMLElement,
text: string,
): Promise<HTMLElement> => waitForSuggestion(canvasElement, text);
/** Focuses the empty filter: every key, with any recent filters above them. */
export const openKeySuggestions = async (
canvasElement: HTMLElement,
row: string,
): Promise<void> => {
await focusFilter(canvasElement);
await waitForSuggestion(canvasElement, row);
};
/**
* Focuses the filter and types onto the end of it one character at a time,
* each as the transaction a keystroke makes, leaving the caret at the end so
* the suggestion list follows what was typed. Quotes and brackets are not
* closed for it: type both.
*
* `userEvent.type` cannot be used: CodeMirror redraws the line as tokens are
* highlighted, which strands the caret `userEvent` tracks.
*/
export const typeFilter = async (
canvasElement: HTMLElement,
text: string,
): Promise<void> => {
const view = await focusFilter(canvasElement);
for (const character of text) {
const at = view.state.doc.length;
view.dispatch({
changes: { from: at, insert: character },
selection: { anchor: at + character.length },
userEvent: 'input.type',
});
}
};
/**
* Types an expression, then steps the caret back inside it, before a closing
* bracket or parenthesis, where the suggestions are about what goes in there.
*/
export const typeFilterWithCaretBack = async (
canvasElement: HTMLElement,
text: string,
stepsBack: number,
row: string,
): Promise<void> => {
await typeFilter(canvasElement, text);
const { view } = currentView(canvasElement);
view.dispatch({
selection: { anchor: view.state.doc.length - stepsBack },
userEvent: 'select',
});
await waitForSuggestion(canvasElement, row);
};
/**
* Moves focus off the filter, which is when the expression is validated and
* the error marker can show.
*/
export const blurFilter = async (canvasElement: HTMLElement): Promise<void> => {
await userEvent.keyboard('{Escape}');
await userEvent.click(canvasElement.ownerDocument.body);
};
/** Types an expression, leaves the filter and opens its validation errors. */
export const showFilterErrors = async (
canvasElement: HTMLElement,
text: string,
): Promise<void> => {
await typeFilter(canvasElement, text);
await blurFilter(canvasElement);
const marker = await waitFor(
() =>
present(
canvasElement.querySelector<HTMLElement>('.query-status-container button'),
'error marker',
),
untilLoaded,
);
await userEvent.hover(marker);
await waitFor(
() =>
present(
canvasElement.ownerDocument.querySelector('.query-validation-error'),
'validation error',
),
untilLoaded,
);
};

View File

@@ -35,7 +35,7 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 10.5px;
padding: var(--spacing-2) 10.5px;
border-bottom: 1px solid var(--l1-border);
.left-actions {
@@ -46,6 +46,9 @@
justify-content: flex-start;
.text {
padding-left: var(--spacing-4);
white-space: nowrap;
flex-shrink: 0;
color: var(--l1-foreground);
font-family: Inter;
font-size: 14px;
@@ -78,6 +81,8 @@
}
.select-box {
--combobox-trigger-width: auto;
display: flex;
padding: 2px 5px;
flex-direction: row;
@@ -95,10 +100,9 @@
.right-actions {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
gap: var(--spacing-1);
flex-shrink: 0;
justify-content: flex-end;
--button-variant-link-color: var(--l1-foreground);
.divider-filter {
width: 1px;
@@ -106,26 +110,8 @@
background: var(--l1-background);
}
.sync-icon {
cursor: pointer;
}
.right-action-icon-container {
position: relative;
display: flex;
padding: 2px;
background-color: var(--l1-background);
.settings-icon {
height: 14px;
width: 14px;
cursor: pointer;
}
&.active,
&:hover {
background: var(--l2-background);
}
.active {
background: var(--l2-background);
}
}
}

View File

@@ -1,7 +1,6 @@
import { useMemo, useState } from 'react';
import {
ArrowUpToLine,
Filter,
Frown,
RefreshCw,
Settings2 as SettingsIcon,
@@ -14,8 +13,9 @@ import {
ComboboxList,
ComboboxTrigger,
} from '@signozhq/ui/combobox';
import { Skeleton, Tooltip } from 'antd';
import { Skeleton } from 'antd';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
@@ -180,7 +180,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
// Helpers to reduce cognitive complexity in main render
const renderLeftActions = (): JSX.Element => (
<section className="left-actions">
<Filter size="md" />
<Typography.Text className="text">
{displayedQueryName ? 'Filters for' : 'Filters'}
</Typography.Text>
@@ -217,13 +216,13 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
</Combobox>
) : (
displayedQueryName && (
<Tooltip
<TooltipSimple
title={`Filter currently in sync with query ${displayedQueryName}`}
>
<Typography.Text className="sync-tag">
{displayedQueryName}
</Typography.Text>
</Tooltip>
</TooltipSimple>
)
)}
</section>
@@ -231,51 +230,54 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
const renderRightActions = (): JSX.Element => (
<section className="right-actions">
<Tooltip title="Reset All">
<TooltipSimple title="Reset All">
<Button
variant="link"
variant="ghost"
color="secondary"
size="icon"
aria-label="Reset All"
className="right-action-icon-container"
onClick={handleReset}
prefix={<RefreshCw className="sync-icon" size="md" />}
prefix={<RefreshCw size={14} />}
/>
</Tooltip>
</TooltipSimple>
{showFilterCollapse && (
<Tooltip title="Collapse Filters">
<TooltipSimple title="Collapse Filters">
<Button
variant="link"
variant="ghost"
color="secondary"
size="icon"
aria-label="Collapse Filters"
className="right-action-icon-container"
onClick={handleFilterVisibilityChange}
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size="md" />}
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size={14} />}
/>
</Tooltip>
)}
{isDynamicFilters && (
<AuthZButton
checks={QuickFilterManagePermissions}
variant="link"
color="secondary"
aria-label="Settings"
className={classNames('right-action-icon-container', {
active: isSettingsOpen,
})}
onClick={(): void => setIsSettingsOpen(true)}
testId="settings-icon-container"
prefix={
<Tooltip title="Settings" open={isSettingsDisabled ? false : undefined}>
<SettingsIcon
className="settings-icon"
data-testid="settings-icon"
width={14}
height={14}
/>
</Tooltip>
}
/>
</TooltipSimple>
)}
{isDynamicFilters &&
(isSettingsDisabled ? (
<AuthZButton
checks={QuickFilterManagePermissions}
variant="ghost"
color="secondary"
size="icon"
aria-label="Settings"
onClick={(): void => setIsSettingsOpen(true)}
testId="settings-icon-container"
prefix={<SettingsIcon data-testid="settings-icon" size={14} />}
/>
) : (
<TooltipSimple title="Settings">
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="Settings"
className={classNames({ active: isSettingsOpen })}
onClick={(): void => setIsSettingsOpen(true)}
data-testid="settings-icon-container"
prefix={<SettingsIcon data-testid="settings-icon" size={14} />}
/>
</TooltipSimple>
))}
</section>
);

View File

@@ -3,6 +3,7 @@ import cx from 'classnames';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import SavedViewsHeader from 'container/SavedViews/SavedViewsHeader';
import SavedViewsPanel from 'container/SavedViews/SavedViewsPanel';
import SavedViewsRestore from 'container/SavedViews/SavedViewsRestore';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import QuickFilters from '../QuickFilters';
@@ -40,7 +41,7 @@ function QuickFiltersLayout({
testId,
children,
}: QuickFiltersLayoutProps): JSX.Element {
const [isViewsOpen, setIsViewsOpen] = useState(false);
const [isViewsListOpen, setIsViewsListOpen] = useState(false);
const isSavedViewEnabled = useSavedViewEnabled();
const hasQuickFilters = !!quickFilterProps;
@@ -48,19 +49,23 @@ function QuickFiltersLayout({
const showSidebar = showFilters && (hasQuickFilters || hasSavedViews);
const isPanelPinned = hasSavedViews && !hasQuickFilters;
const isViewsListPinned = hasSavedViews && !hasQuickFilters;
const isViewsListVisible = isViewsListPinned || isViewsListOpen;
const isSliding = hasQuickFilters && isViewsOpen;
const isSliding = hasQuickFilters && isViewsListOpen;
const savedViewsHeader = hasSavedViews ? (
<SavedViewsHeader
{...savedViewProps}
onOpenViews={isPanelPinned ? undefined : (): void => setIsViewsOpen(true)}
onOpenViews={
isViewsListVisible ? undefined : (): void => setIsViewsListOpen(true)
}
/>
) : undefined;
return (
<div className={cx(styles.layout, className)} data-testid={testId}>
{hasSavedViews && <SavedViewsRestore source={savedViewProps.source} />}
{showSidebar && (
<aside
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
@@ -80,10 +85,12 @@ function QuickFiltersLayout({
)}
</div>
<div className={styles.savedViews}>
{hasSavedViews && (isPanelPinned || isViewsOpen) && (
{hasSavedViews && isViewsListVisible && (
<SavedViewsPanel
{...savedViewProps}
onClose={isPanelPinned ? undefined : (): void => setIsViewsOpen(false)}
onClose={
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
}
/>
)}
</div>

View File

@@ -23,6 +23,13 @@ jest.mock('hooks/useSavedViewEnabled', () => ({
useSavedViewEnabled: jest.fn(() => true),
}));
jest.mock('container/SavedViews/SavedViewsRestore', () => ({
__esModule: true,
default: ({ source }: { source: string }): JSX.Element => (
<div data-testid="saved-views-restore" data-source={source} />
),
}));
jest.mock('../../QuickFilters', () => ({
__esModule: true,
default: ({
@@ -87,6 +94,10 @@ describe('QuickFiltersLayout', () => {
).not.toBeInTheDocument();
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
expect(screen.getByText('content')).toBeInTheDocument();
expect(screen.getByTestId('saved-views-restore')).toHaveAttribute(
'data-source',
SavedviewtypesSourceDTO.traces,
);
});
it('renders no sidebar at all without quick filters or saved views', () => {
@@ -149,6 +160,7 @@ describe('QuickFiltersLayout', () => {
expect(
screen.queryByTestId('quick-filters-layout-filters'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-restore')).not.toBeInTheDocument();
});
it('opens the panel from the header and slides the quick filters drawer, keeping the same QuickFilters node', async () => {
@@ -176,12 +188,14 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(drawer()).toHaveClass('isOpen');
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
await user.click(screen.getByTestId('saved-views-close'));
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
});
it('hands the source to the header and the panel', async () => {

View File

@@ -117,3 +117,38 @@ export const Tooltips: TooltipsStory = {
msw: { handlers: [attributeValuesHandler(LONG_FILTER_VALUES), ...handlers] },
},
};
const [firstQuery] = queryBuilder.currentQuery.builder.queryData;
/** Interaction: the query picker lists every query the filters can follow. */
export const QueryPickerOpen: Story = {
parameters: {
signoz: {
queryBuilder: {
...queryBuilder,
currentQuery: {
builder: {
queryData: [
{ ...firstQuery, queryName: 'A' },
{ ...firstQuery, queryName: 'B' },
],
},
},
},
},
},
play: async ({ canvasElement }): Promise<void> => {
const trigger = await waitFor(() => {
const element = canvasElement.querySelector<HTMLElement>(
'[data-slot="combobox-trigger"]',
);
expect(element).toBeInTheDocument();
return element as HTMLElement;
});
await userEvent.click(trigger);
await screen.findByRole('listbox');
},
};

View File

@@ -233,12 +233,23 @@ function TanStackTableInner<TData, TItemKey = string>(
[effectiveColumns],
);
// A rebuilt column def is a new cell component to React, which remounts every
// cell it renders. Read the row callbacks through refs so inline ones do not.
const isRowActiveRef = useRef(isRowActive);
isRowActiveRef.current = isRowActive;
const getRowKeyDataRef = useRef(getRowKeyData);
getRowKeyDataRef.current = getRowKeyData;
const tanstackColumns = useMemo<ColumnDef<TData>[]>(
() =>
effectiveColumns.map((colDef) =>
buildTanstackColumnDef<TData, TItemKey>(colDef, isRowActive, getRowKeyData),
buildTanstackColumnDef<TData, TItemKey>(
colDef,
(row) => isRowActiveRef.current?.(row) ?? false,
(index) => getRowKeyDataRef.current(index),
),
),
[effectiveColumns, isRowActive, getRowKeyData],
[effectiveColumns],
);
const getRowId = useCallback(

View File

@@ -1,8 +1,16 @@
import { useEffect, useState } from 'react';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UrlUpdateEvent } from 'nuqs/adapters/testing';
import { renderTanStackTable } from './testUtils';
import TanStackTable from '../index';
import type { TableColumnDef } from '../types';
import {
defaultData,
renderTanStackTable,
renderWithProviders,
type TestRow,
} from './testUtils';
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
@@ -810,6 +818,58 @@ describe('TanStackTableView Integration', () => {
});
});
describe('cell identity', () => {
it('keeps cells mounted across re-renders that pass new inline row callbacks', async () => {
const user = userEvent.setup();
const onMount = jest.fn();
function MountTracker({ id }: { id: string }): JSX.Element {
useEffect(() => onMount(id), [id]);
return <span data-testid={`tracked-${id}`}>Row {id}</span>;
}
const columns: TableColumnDef<TestRow>[] = [
{
id: 'id',
header: 'ID',
accessorKey: 'id',
cell: ({ row }): JSX.Element => <MountTracker id={row.id} />,
},
];
function Harness(): JSX.Element {
const [, setRenders] = useState(0);
return (
<>
<button
type="button"
data-testid="rerender"
onClick={(): void => setRenders((count) => count + 1)}
>
Re-render
</button>
<TanStackTable<TestRow>
data={defaultData}
columns={columns}
getRowKey={(row): string => row.id}
isRowActive={(row): boolean => row.id === '1'}
/>
</>
);
}
renderWithProviders(<Harness />);
await screen.findByTestId('tracked-2');
onMount.mockClear();
await user.click(screen.getByTestId('rerender'));
await user.hover(screen.getByTestId('tracked-2'));
expect(onMount).not.toHaveBeenCalled();
});
});
describe('disableVirtualScroll', () => {
it('throws error when used with onEndReached', () => {
expect(() => {

View File

@@ -143,6 +143,19 @@ export const Pagination: Story = {
},
};
/** Interaction: the page size menu lists the row counts a page can show. */
export const PageSizeOpen: Story = {
args: {
pagination: { total: 42, defaultLimit: 10, showTotalCount: true },
},
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByTestId('pagination-page-size'),
);
await screen.findByRole('listbox');
},
};
/** Data: the supported empty result keeps the table structure without a fabricated empty state. */
export const Empty: Story = {
args: { data: [], testId: 'tanstack-empty-table' },

View File

@@ -5,12 +5,10 @@ import {
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
findSavedView,
getSavedViewQuery,
SavedViewSourcePage,
toSavedViewSource,
} from 'container/SavedViews/utils';
import { SavedViewSourcePage } from 'container/SavedViews/types';
import { findSavedView } from 'container/SavedViews/utils/findSavedView';
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource';
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
import { DataSource } from 'types/common/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';

View File

@@ -8,7 +8,7 @@ import {
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { getSavedViewQuery } from 'container/SavedViews/utils';
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
import Card from 'periscope/components/Card/Card';

View File

@@ -28,6 +28,9 @@ import {
useInfraMonitoringOrderBy,
useInfraMonitoringSelectedItemParams,
useInfraMonitoringStatusFilter,
useInfraMonitoringPodStatusFilter,
useInfraMonitoringNodeReadinessFilter,
useInfraMonitoringContainerStatusFilter,
} from '../hooks';
import {
useInfraMonitoringFontSize,
@@ -97,6 +100,10 @@ export type K8sBaseListProps<
detailsQueryKeyPrefix: string;
};
function emptyToUndefined<T>(values: T[]): T[] | undefined {
return values.length > 0 ? values : undefined;
}
export function K8sBaseList<
T extends K8sEntityData,
TItemKey extends string | SelectedItemParams = string,
@@ -120,6 +127,9 @@ export function K8sBaseList<
const [groupBy] = useInfraMonitoringGroupBy();
const [orderBy] = useInfraMonitoringOrderBy();
const [statusFilter] = useInfraMonitoringStatusFilter();
const [podStatusFilter] = useInfraMonitoringPodStatusFilter();
const [nodeReadinessFilter] = useInfraMonitoringNodeReadinessFilter();
const [containerStatusFilter] = useInfraMonitoringContainerStatusFilter();
const [selectedItemParams, setSelectedItemParams] =
useInfraMonitoringSelectedItemParams();
const selectedItem = selectedItemParams.selectedItem;
@@ -171,6 +181,9 @@ export function K8sBaseList<
JSON.stringify(orderBy),
JSON.stringify(groupBy),
statusFilter,
podStatusFilter.join(),
nodeReadinessFilter.join(),
containerStatusFilter.join(),
...extraQueryKeyParts,
);
}, [
@@ -183,6 +196,9 @@ export function K8sBaseList<
orderBy,
groupBy,
statusFilter,
podStatusFilter,
nodeReadinessFilter,
containerStatusFilter,
extraQueryKeyParts,
]);
@@ -203,6 +219,9 @@ export function K8sBaseList<
statusFilter === 'active' || statusFilter === 'inactive'
? statusFilter
: undefined,
filterByPodStatus: emptyToUndefined(podStatusFilter),
filterByNodeReadiness: emptyToUndefined(nodeReadinessFilter),
filterByContainerStatus: emptyToUndefined(containerStatusFilter),
},
groupBy:
groupBy && groupBy.length > 0

View File

@@ -285,6 +285,9 @@ describe('K8sBaseList', () => {
expect(filters.filter).toStrictEqual({
expression: '',
filterByStatus: undefined,
filterByPodStatus: undefined,
filterByNodeReadiness: undefined,
filterByContainerStatus: undefined,
});
expect(filters.groupBy).toBeUndefined();
expect(filters.orderBy).toBeUndefined();
@@ -1668,3 +1671,61 @@ describe('K8sBaseList', () => {
});
});
});
describe('K8sBaseList status filters', () => {
const fetchListDataMock = jest.fn<
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
>();
function renderWithParams(queryParams: Record<string, string>): void {
fetchListDataMock.mockClear();
fetchListDataMock.mockResolvedValue({ data: [], total: 0, error: null });
renderComponent<TestItem>({
queryParams,
entity: InfraMonitoringEntity.PODS,
eventCategory: InfraMonitoringEvents.Pod,
fetchListData: fetchListDataMock,
tableColumns: createTestColumns(),
getRowKey: (row): string => row.id,
getItemKey: (row): string => row.id,
});
}
it('sends the statuses named in the URL', async () => {
renderWithParams({ podStatus: 'running,crashloopbackoff' });
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
const [filters] = fetchListDataMock.mock.calls[0];
expect(filters.filter.filterByPodStatus).toStrictEqual([
'running',
'crashloopbackoff',
]);
});
it('drops unknown statuses the API would reject', async () => {
renderWithParams({ podStatus: 'running,no_data,bogus' });
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
const [filters] = fetchListDataMock.mock.calls[0];
expect(filters.filter.filterByPodStatus).toStrictEqual(['running']);
});
it('omits the filter entirely when no status is selected', async () => {
renderWithParams({});
await waitFor(() => {
expect(fetchListDataMock).toHaveBeenCalled();
});
const [filters] = fetchListDataMock.mock.calls[0];
expect(filters.filter.filterByPodStatus).toBeUndefined();
});
});

View File

@@ -0,0 +1,21 @@
.statusFilterGroup {
display: flex;
align-items: center;
gap: 12px;
}
.statusFilter {
display: flex;
align-items: center;
gap: 8px;
}
.statusFilterLabel {
color: var(--muted-foreground);
font-size: var(--periscope-font-size-base);
white-space: nowrap;
}
.statusFilterControl {
min-width: 180px;
}

View File

@@ -0,0 +1,121 @@
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import {
FILTERABLE_CONTAINER_STATUSES,
FILTERABLE_NODE_CONDITIONS,
FILTERABLE_POD_STATUSES,
useInfraMonitoringCategory,
useInfraMonitoringContainerStatusFilter,
useInfraMonitoringNodeReadinessFilter,
useInfraMonitoringPodStatusFilter,
} from 'container/InfraMonitoringK8sV2/hooks';
import StatusMultiSelect from './StatusMultiSelect';
import { StatusFilterKind } from '../StatusFilterCells/useStatusFilter';
import {
CONTAINER_STATUS_FILTER_OPTIONS,
NODE_READINESS_FILTER_OPTIONS,
POD_STATUS_FILTER_OPTIONS,
} from './statusFilterOptions';
import styles from './EntityStatusFilter.module.scss';
interface StatusFilterSpec {
kind: StatusFilterKind;
/** Matches the entity's own status column header, so filter and column agree. */
label: string;
}
const POD_STATUS_SPEC: StatusFilterSpec = { kind: 'pod', label: 'Pod Status' };
const NODE_READINESS_SPEC: StatusFilterSpec = {
kind: 'node',
label: 'Node Readiness',
};
/**
* Which secondary status filters each list accepts — mirrors the filter structs in
* pkg/types/inframonitoringtypes. Volumes take none, so they are absent here.
*/
const STATUS_FILTERS_BY_ENTITY: Partial<
Record<InfraMonitoringEntity, StatusFilterSpec[]>
> = {
[InfraMonitoringEntity.PODS]: [{ kind: 'pod', label: 'Status' }],
[InfraMonitoringEntity.CONTAINERS]: [{ kind: 'container', label: 'Status' }],
[InfraMonitoringEntity.NODES]: [NODE_READINESS_SPEC, POD_STATUS_SPEC],
[InfraMonitoringEntity.CLUSTERS]: [NODE_READINESS_SPEC, POD_STATUS_SPEC],
[InfraMonitoringEntity.NAMESPACES]: [POD_STATUS_SPEC],
[InfraMonitoringEntity.DEPLOYMENTS]: [POD_STATUS_SPEC],
[InfraMonitoringEntity.DAEMONSETS]: [POD_STATUS_SPEC],
[InfraMonitoringEntity.STATEFULSETS]: [POD_STATUS_SPEC],
[InfraMonitoringEntity.JOBS]: [POD_STATUS_SPEC],
};
const FILTER_KEY_BY_KIND: Record<StatusFilterKind, string> = {
pod: 'pod_status',
node: 'node_readiness',
container: 'container_status',
};
const OPTIONS_BY_KIND = {
pod: POD_STATUS_FILTER_OPTIONS,
node: NODE_READINESS_FILTER_OPTIONS,
container: CONTAINER_STATUS_FILTER_OPTIONS,
};
const ALL_VALUES_BY_KIND = {
pod: FILTERABLE_POD_STATUSES,
node: FILTERABLE_NODE_CONDITIONS,
container: FILTERABLE_CONTAINER_STATUSES,
};
function EntityStatusFilter(): JSX.Element | null {
const [category] = useInfraMonitoringCategory();
const [podStatus, setPodStatus] = useInfraMonitoringPodStatusFilter();
const [nodeReadiness, setNodeReadiness] =
useInfraMonitoringNodeReadinessFilter();
const [containerStatus, setContainerStatus] =
useInfraMonitoringContainerStatusFilter();
const specs = STATUS_FILTERS_BY_ENTITY[category as InfraMonitoringEntity];
if (!specs) {
return null;
}
const selectedByKind: Record<StatusFilterKind, string[]> = {
pod: podStatus,
node: nodeReadiness,
container: containerStatus,
};
const setterByKind: Record<StatusFilterKind, (next: string[]) => void> = {
pod: (next): void =>
void setPodStatus(next.length > 0 ? (next as typeof podStatus) : null),
node: (next): void =>
void setNodeReadiness(
next.length > 0 ? (next as typeof nodeReadiness) : null,
),
container: (next): void =>
void setContainerStatus(
next.length > 0 ? (next as typeof containerStatus) : null,
),
};
return (
<div className={styles.statusFilterGroup}>
{specs.map((spec) => (
<StatusMultiSelect
key={spec.kind}
label={spec.label}
entity={category as InfraMonitoringEntity}
filterKey={FILTER_KEY_BY_KIND[spec.kind]}
options={OPTIONS_BY_KIND[spec.kind]}
allValues={ALL_VALUES_BY_KIND[spec.kind]}
selected={selectedByKind[spec.kind]}
onChange={setterByKind[spec.kind]}
testId={`${spec.kind}-status-filter`}
/>
))}
</div>
);
}
export default EntityStatusFilter;

View File

@@ -0,0 +1,102 @@
import { useMemo } from 'react';
import { CustomMultiSelect } from 'components/NewSelect';
import OverflowValuesTooltip from 'components/OverflowValuesTooltip/OverflowValuesTooltip';
import { OptionData } from 'components/NewSelect/types';
import { findOptionLabel } from 'components/NewSelect/utils';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
import { useInfraMonitoringPageListing } from 'container/InfraMonitoringK8sV2/hooks';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { logInfraFilterCustomizedEvent } from '../../events';
import styles from './EntityStatusFilter.module.scss';
interface StatusMultiSelectProps<T extends string> {
label: string;
entity: InfraMonitoringEntity;
/** Key reported to analytics for this control, e.g. `pod_status`. */
filterKey: string;
options: OptionData[];
allValues: T[];
selected: T[];
onChange: (next: T[]) => void;
testId: string;
}
function StatusMultiSelect<T extends string>({
label,
entity,
filterKey,
options,
allValues,
selected,
onChange,
testId,
}: StatusMultiSelectProps<T>): JSX.Element {
const { currentQuery } = useQueryBuilder();
// No selection means no filter, which is every status — so show ALL as checked
// rather than an empty control that reads as "nothing matches".
const value = useMemo(
() => (selected.length > 0 ? selected : allValues),
[selected, allValues],
);
const handleChange = (next: string[] | string | undefined): void => {
const values = (Array.isArray(next) ? next : [next])
.filter((item): item is string => Boolean(item))
.filter((item): item is T => allValues.includes(item as T));
// Selecting everything is the same request as selecting nothing; store the
// empty form so the param drops out of the URL.
const isAll = values.length === allValues.length;
onChange(isAll ? [] : values);
logInfraFilterCustomizedEvent(
entity,
'status_filter',
currentQuery.builder.queryData[0]?.filter?.expression || '',
isAll ? [] : [filterKey],
);
};
const [, setCurrentPage] = useInfraMonitoringPageListing();
return (
<div className={styles.statusFilter}>
<span className={styles.statusFilterLabel}>{label}</span>
<CustomMultiSelect
className={styles.statusFilterControl}
data-testid={testId}
options={options}
value={value}
showSearch
showLabels
preserveOptionOrder
maxTagCount={1}
maxTagTextLength={12}
maxTagPlaceholder={(omitted): JSX.Element => (
<OverflowValuesTooltip
values={omitted.map((item) => {
// The omitted items carry the raw value as their label, since
// sections put the real one a level down.
const value = String(item.value ?? '');
return findOptionLabel(options, value) ?? value;
})}
/>
)}
placeholder={label}
onChange={(next): void => {
handleChange(next);
void setCurrentPage(1);
}}
onClear={(): void => {
onChange([]);
void setCurrentPage(1);
}}
/>
</div>
);
}
export default StatusMultiSelect;

View File

@@ -0,0 +1,64 @@
import { BadgeColor } from '@signozhq/ui/badge';
import {
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import { OptionData } from 'components/NewSelect/types';
import {
FILTERABLE_CONTAINER_STATUSES,
FILTERABLE_NODE_CONDITIONS,
FILTERABLE_POD_STATUSES,
} from '../../../hooks';
import { POD_STATUS_COLORS, POD_STATUS_LABELS } from '../../../commonUtils';
import {
CONTAINER_STATUS_COLORS,
CONTAINER_STATUS_LABELS,
} from '../../../Containers/utils';
import { NODE_CONDITION_LABELS } from '../../../Nodes/utils';
const ERROR_BADGE_COLOR: BadgeColor = 'cherry';
/**
* Splits statuses into the two sections the dropdown renders. The badge colour is
* already the severity signal in the table, so reusing it keeps the sections and
* the status column from drifting apart.
*/
function toSectionedOptions<T extends string>(
values: T[],
labels: Record<T, string>,
colors: Record<T, BadgeColor>,
): OptionData[] {
const toOption = (value: T): OptionData => ({
label: labels[value],
value,
});
const lifecycle = values.filter(
(value) => colors[value] !== ERROR_BADGE_COLOR,
);
const errors = values.filter((value) => colors[value] === ERROR_BADGE_COLOR);
return [
{ label: 'Lifecycle', options: lifecycle.map(toOption) },
{ label: 'Error status', options: errors.map(toOption) },
].filter((section) => section.options.length > 0);
}
export const POD_STATUS_FILTER_OPTIONS = toSectionedOptions(
FILTERABLE_POD_STATUSES as InframonitoringtypesPodStatusDTO[],
POD_STATUS_LABELS,
POD_STATUS_COLORS,
);
export const CONTAINER_STATUS_FILTER_OPTIONS = toSectionedOptions(
FILTERABLE_CONTAINER_STATUSES as InframonitoringtypesContainerStatusDTO[],
CONTAINER_STATUS_LABELS,
CONTAINER_STATUS_COLORS,
);
/** Only two values, so sections would add a heading per row. */
export const NODE_READINESS_FILTER_OPTIONS: OptionData[] = (
FILTERABLE_NODE_CONDITIONS as InframonitoringtypesNodeConditionDTO[]
).map((value) => ({ label: NODE_CONDITION_LABELS[value], value }));

View File

@@ -0,0 +1,11 @@
.statusBadgeButton {
display: inline-flex;
padding: 0;
background: none;
border: none;
cursor: pointer;
font: inherit;
// The badge sets its own cursor, so the button's never reaches it.
--badge-cursor: pointer;
}

View File

@@ -0,0 +1,57 @@
import { Badge, BadgeColor } from '@signozhq/ui/badge';
import TanStackTable from 'components/TanStackTableView';
import { StatusFilterKind, useStatusFilter } from './useStatusFilter';
import styles from './ClickableStatusBadge.module.scss';
interface ClickableStatusBadgeProps {
color: BadgeColor;
label: string;
/** The status this badge stands for, applied as the filter when clicked. */
status: string;
kind: StatusFilterKind;
rowId: string;
}
function ClickableStatusBadge({
color,
label,
status,
kind,
rowId,
}: ClickableStatusBadgeProps): JSX.Element {
const { selected, setStatusFilter } = useStatusFilter(kind);
// Clicking the status the list is already narrowed to has nothing left to
// narrow, so it undoes the filter instead — but only when this status is the
// whole filter, else it would silently drop the other selected statuses.
const isWholeFilter = selected.length === 1 && selected[0] === status;
return (
<TanStackTable.HoverTooltip
rowId={rowId}
title={isWholeFilter ? `Clear ${label} filter` : `Filter by ${label}`}
arrow
align="start"
delayDuration={300}
>
<button
type="button"
className={styles.statusBadgeButton}
data-testid={`status-badge-${status}`}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
setStatusFilter(isWholeFilter ? [] : [status]);
}}
>
<Badge color={color} variant="outline">
{label}
</Badge>
</button>
</TanStackTable.HoverTooltip>
);
}
export default ClickableStatusBadge;

View File

@@ -0,0 +1,33 @@
import {
GroupedStatusCounts,
StatusCountItem,
} from 'container/InfraMonitoringK8sV2/components';
import { StatusFilterKind, useStatusFilter } from './useStatusFilter';
interface ClickableStatusCountsProps {
items: StatusCountItem[];
rowId: string;
kind: StatusFilterKind;
showZeroValues?: boolean;
}
function ClickableStatusCounts({
items,
rowId,
kind,
showZeroValues,
}: ClickableStatusCountsProps): JSX.Element {
const { setStatusFilter } = useStatusFilter(kind);
return (
<GroupedStatusCounts
items={items}
rowId={rowId}
showZeroValues={showZeroValues}
onSelectStatuses={setStatusFilter}
/>
);
}
export default ClickableStatusCounts;

View File

@@ -0,0 +1,67 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NuqsTestingAdapter, UrlUpdateEvent } from 'nuqs/adapters/testing';
import ClickableStatusBadge from '../ClickableStatusBadge';
function renderBadge(
searchParams: Record<string, string>,
onUrlUpdate: jest.Mock<void, [UrlUpdateEvent]>,
): void {
render(
<NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate}>
<ClickableStatusBadge
color="cherry"
label="CrashLoopBackOff"
status="crashloopbackoff"
kind="pod"
rowId="row-1"
/>
</NuqsTestingAdapter>,
);
}
describe('ClickableStatusBadge', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
beforeEach(() => {
onUrlUpdate.mockClear();
});
it('filters by its status when nothing is filtered', async () => {
renderBadge({}, onUrlUpdate);
await userEvent.click(screen.getByTestId('status-badge-crashloopbackoff'));
await waitFor(() => {
expect(onUrlUpdate.mock.calls[0][0].searchParams.get('podStatus')).toBe(
'crashloopbackoff',
);
});
});
it('clears the filter when its status is the whole filter', async () => {
renderBadge({ podStatus: 'crashloopbackoff' }, onUrlUpdate);
await userEvent.click(screen.getByTestId('status-badge-crashloopbackoff'));
await waitFor(() => {
expect(
onUrlUpdate.mock.calls[0][0].searchParams.get('podStatus'),
).toBeNull();
});
});
it('still filters when its status is only part of the filter', async () => {
// Clearing here would silently drop oomkilled, which the user also picked.
renderBadge({ podStatus: 'crashloopbackoff,oomkilled' }, onUrlUpdate);
await userEvent.click(screen.getByTestId('status-badge-crashloopbackoff'));
await waitFor(() => {
expect(onUrlUpdate.mock.calls[0][0].searchParams.get('podStatus')).toBe(
'crashloopbackoff',
);
});
});
});

View File

@@ -0,0 +1,60 @@
import { useCallback } from 'react';
import {
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
useInfraMonitoringContainerStatusFilter,
useInfraMonitoringNodeReadinessFilter,
useInfraMonitoringPageListing,
useInfraMonitoringPodStatusFilter,
} from '../../../hooks';
export type StatusFilterKind = 'pod' | 'node' | 'container';
interface StatusFilter {
selected: string[];
/**
* Replaces the filter rather than adding to it — a click on a status means
* "show me these", not "add these to what I already have". Empty clears it.
*/
setStatusFilter: (statuses: string[]) => void;
}
export function useStatusFilter(kind: StatusFilterKind): StatusFilter {
const [podStatus, setPodStatus] = useInfraMonitoringPodStatusFilter();
const [nodeStatus, setNodeStatus] = useInfraMonitoringNodeReadinessFilter();
const [containerStatus, setContainerStatus] =
useInfraMonitoringContainerStatusFilter();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const setStatusFilter = useCallback(
(statuses: string[]): void => {
const next = statuses.length > 0 ? statuses : null;
if (kind === 'pod') {
void setPodStatus(next as InframonitoringtypesPodStatusDTO[] | null);
} else if (kind === 'node') {
void setNodeStatus(next as InframonitoringtypesNodeConditionDTO[] | null);
} else {
void setContainerStatus(
next as InframonitoringtypesContainerStatusDTO[] | null,
);
}
void setCurrentPage(1);
},
[kind, setPodStatus, setNodeStatus, setContainerStatus, setCurrentPage],
);
let selected: string[] = containerStatus;
if (kind === 'pod') {
selected = podStatus;
} else if (kind === 'node') {
selected = nodeStatus;
}
return { selected, setStatusFilter };
}

View File

@@ -6,7 +6,7 @@ import { isCustomTimeRange } from 'store/globalTime';
export function logInfraFilterCustomizedEvent(
entityType: InfraMonitoringEntity,
source: 'quick_filter' | 'search' | 'host_status_toggle',
source: 'quick_filter' | 'search' | 'host_status_toggle' | 'status_filter',
expression: string,
extraKeys?: string[],
): void {

View File

@@ -1,4 +1,9 @@
import { ReactNode } from 'react';
import {
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
CustomTimeType,
Time,
@@ -17,6 +22,9 @@ export type K8sBaseFilters = {
filter: {
expression: string;
filterByStatus?: 'active' | 'inactive' | '';
filterByPodStatus?: InframonitoringtypesPodStatusDTO[];
filterByNodeReadiness?: InframonitoringtypesNodeConditionDTO[];
filterByContainerStatus?: InframonitoringtypesContainerStatusDTO[];
};
groupBy?: Array<{ name: string }>;
offset?: number;

View File

@@ -38,7 +38,11 @@ async function fetchListData(
try {
const response = await listClusters(
{
filter: { expression: filters.filter.expression },
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
filterByNodeReadiness: filters.filter.filterByNodeReadiness,
},
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -1,19 +1,19 @@
import { Color } from '@signozhq/design-tokens';
import { Boxes } from '@signozhq/icons';
import { InframonitoringtypesClusterRecordDTO } from 'api/generated/services/sigNoz.schemas';
import {
InframonitoringtypesClusterRecordDTO,
InframonitoringtypesNodeConditionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import { TextNoData, ValidateColumnValueWrapper } from '../components';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
@@ -98,18 +98,21 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="node"
rowId={rowId}
items={[
{
value: row.nodeCountsByReadiness.ready,
label: 'Ready',
color: Color.BG_FOREST_500,
statuses: [InframonitoringtypesNodeConditionDTO.ready],
},
{
value: row.nodeCountsByReadiness.notReady,
label: 'Not Ready',
color: Color.BG_AMBER_500,
statuses: [InframonitoringtypesNodeConditionDTO.not_ready],
},
]}
/>
@@ -135,7 +138,8 @@ export const k8sClustersColumnsConfig: ClusterTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="pod"
rowId={rowId}
items={getPodStatusItems(row.podCountsByStatus)}
/>

View File

@@ -40,7 +40,10 @@ async function fetchListData(
try {
const response = await listContainers(
{
filter: { expression: filters.filter.expression },
filter: {
expression: filters.filter.expression,
filterByContainerStatus: filters.filter.filterByContainerStatus,
},
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -9,6 +9,8 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusBadge from '../Base/components/StatusFilterCells/ClickableStatusBadge';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes } from '../commonUtils';
@@ -173,7 +175,7 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
width: { min: STATUS_COLUMN_WIDTH },
enableSort: false,
visibilityBehavior: 'hidden-on-expand',
cell: ({ row }): React.ReactNode => {
cell: ({ row, rowId }): React.ReactNode => {
if (
!row.status ||
row.status === InframonitoringtypesContainerStatusDTO.no_data
@@ -182,9 +184,13 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
}
return (
<Badge color={CONTAINER_STATUS_COLORS[row.status]} variant="outline">
{CONTAINER_STATUS_LABELS[row.status]}
</Badge>
<ClickableStatusBadge
color={CONTAINER_STATUS_COLORS[row.status]}
label={CONTAINER_STATUS_LABELS[row.status]}
status={row.status}
kind="container"
rowId={rowId}
/>
);
},
},
@@ -205,7 +211,8 @@ export const k8sContainerColumnsConfig: ContainerTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="container"
items={getContainerStatusItems(row.containerCountsByStatus)}
rowId={rowId}
/>

View File

@@ -104,6 +104,20 @@ const CONTAINER_ERROR_STATUS_LABELS: Partial<
containerCannotRun: 'ContainerCannotRun',
};
const CONTAINER_STATUS_VALUES = new Set<string>(
Object.values(InframonitoringtypesContainerStatusDTO),
);
/** See countKeyToPodStatus in commonUtils — the counts DTO uses camelCase keys. */
function countKeyToContainerStatus(
key: string,
): InframonitoringtypesContainerStatusDTO | null {
const value = key.toLowerCase();
return CONTAINER_STATUS_VALUES.has(value)
? (value as InframonitoringtypesContainerStatusDTO)
: null;
}
export function getContainerStatusItems(
counts: InframonitoringtypesContainerCountsByStatusDTO,
): StatusCountItem[] {
@@ -111,32 +125,67 @@ export function getContainerStatusItems(
keyof typeof CONTAINER_ERROR_STATUS_LABELS
>;
const errorBreakdown = errorKeys.map((key) => ({
label: CONTAINER_ERROR_STATUS_LABELS[key] as string,
value: counts[key],
statuses: [countKeyToContainerStatus(key)].filter(
(status): status is InframonitoringtypesContainerStatusDTO =>
status !== null,
),
}));
return [
{ value: counts.running, label: 'Running', color: Color.BG_FOREST_500 },
{ value: counts.completed, label: 'Completed', color: Color.BG_ROBIN_500 },
{
value: counts.running,
label: 'Running',
color: Color.BG_FOREST_500,
statuses: [InframonitoringtypesContainerStatusDTO.running],
},
{
value: counts.completed,
label: 'Completed',
color: Color.BG_ROBIN_500,
statuses: [InframonitoringtypesContainerStatusDTO.completed],
},
{
value: counts.waiting + counts.containerCreating,
label: 'Waiting',
color: Color.BG_AMBER_500,
breakdown: [
{ label: 'Waiting', value: counts.waiting },
{ label: 'ContainerCreating', value: counts.containerCreating },
{
label: 'Waiting',
value: counts.waiting,
statuses: [InframonitoringtypesContainerStatusDTO.waiting],
},
{
label: 'ContainerCreating',
value: counts.containerCreating,
statuses: [InframonitoringtypesContainerStatusDTO.containercreating],
},
],
statuses: [
InframonitoringtypesContainerStatusDTO.waiting,
InframonitoringtypesContainerStatusDTO.containercreating,
],
},
{
value: counts.terminated,
label: 'Terminated',
color: Color.BG_SIENNA_500,
statuses: [InframonitoringtypesContainerStatusDTO.terminated],
},
{
value: counts.unknown,
label: 'Unknown',
color: Color.BG_SLATE_400,
statuses: [InframonitoringtypesContainerStatusDTO.unknown],
},
{ value: counts.unknown, label: 'Unknown', color: Color.BG_SLATE_400 },
{
value: errorKeys.reduce((sum, key) => sum + counts[key], 0),
label: 'Error Status',
color: Color.BG_CHERRY_500,
breakdown: errorKeys.map((key) => ({
label: CONTAINER_ERROR_STATUS_LABELS[key] as string,
value: counts[key],
})),
breakdown: errorBreakdown,
statuses: errorBreakdown.flatMap((item) => item.statuses),
},
];
}

View File

@@ -42,7 +42,10 @@ async function fetchListData(
try {
const response = await listDaemonSets(
{
filter: { expression: filters.filter.expression },
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,6 +4,7 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
@@ -126,7 +127,8 @@ export const k8sDaemonSetsColumnsConfig: DaemonSetTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="pod"
rowId={rowId}
items={getPodStatusItems(podCountsByStatus)}
/>

View File

@@ -46,7 +46,10 @@ async function fetchListData(
try {
const response = await listDeployments(
{
filter: { expression: filters.filter.expression },
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,6 +4,7 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
@@ -124,7 +125,8 @@ export const k8sDeploymentsColumnsConfig: TableColumnDef<InframonitoringtypesDep
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="pod"
rowId={rowId}
items={getPodStatusItems(podCountsByStatus)}
/>

View File

@@ -18,6 +18,7 @@ import { DataSource } from 'types/common/queryBuilder';
import { CategoryIcon } from './Base/categoryIcons';
import { K8sDynamicList } from './Base/K8sDynamicList';
import EntityStatusFilter from './Base/components/EntityStatusFilter/EntityStatusFilter';
import {
GetClustersQuickFiltersConfig,
GetContainersQuickFiltersConfig,
@@ -36,9 +37,12 @@ import {
} from './constants';
import {
useInfraMonitoringCategory,
useInfraMonitoringContainerStatusFilter,
useInfraMonitoringGroupBy,
useInfraMonitoringNodeReadinessFilter,
useInfraMonitoringOrderBy,
useInfraMonitoringPageListing,
useInfraMonitoringPodStatusFilter,
useInfraMonitoringSelectedItemParams,
} from './hooks';
@@ -60,6 +64,9 @@ export default function InfraMonitoringK8s(): JSX.Element {
const [, setOrderBy] = useInfraMonitoringOrderBy();
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
const [, setCurrentPage] = useInfraMonitoringPageListing();
const [, setPodStatusFilter] = useInfraMonitoringPodStatusFilter();
const [, setNodeReadinessFilter] = useInfraMonitoringNodeReadinessFilter();
const [, setContainerStatusFilter] = useInfraMonitoringContainerStatusFilter();
const compositeQuery = useGetCompositeQueryParam();
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
@@ -219,6 +226,9 @@ export default function InfraMonitoringK8s(): JSX.Element {
void setGroupBy(null);
void setCurrentPage(null);
setSelectedItemParams(null);
void setPodStatusFilter(null);
void setNodeReadinessFilter(null);
void setContainerStatusFilter(null);
redirectWithQueryBuilderData({
...currentQuery,
builder: {
@@ -326,7 +336,10 @@ export default function InfraMonitoringK8s(): JSX.Element {
showFilters ? styles.listContainerFiltersVisible : ''
}`}
>
<K8sDynamicList controlListPrefix={showFiltersComp} />
<K8sDynamicList
controlListPrefix={showFiltersComp}
leftFilters={<EntityStatusFilter />}
/>
</div>
</div>
</div>

View File

@@ -42,7 +42,10 @@ async function fetchListData(
try {
const response = await listJobs(
{
filter: { expression: filters.filter.expression },
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,6 +4,7 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
@@ -118,7 +119,8 @@ export const k8sJobsColumnsConfig: JobTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="pod"
items={getPodStatusItems(podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -44,7 +44,10 @@ async function fetchListData(
try {
const response = await listNamespaces(
{
filter: { expression: filters.filter.expression },
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,14 +4,11 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import {
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import { TextNoData, ValidateColumnValueWrapper } from '../components';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
@@ -119,7 +116,8 @@ export const k8sNamespacesColumnsConfig: NamespaceTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="pod"
items={getPodStatusItems(row.podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -39,7 +39,11 @@ async function fetchListData(
try {
const response = await listNodes(
{
filter: { expression: filters.filter.expression },
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
filterByNodeReadiness: filters.filter.filterByNodeReadiness,
},
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -1,20 +1,21 @@
import { Color } from '@signozhq/design-tokens';
import { Badge, BadgeColor } from '@signozhq/ui/badge';
import { InframonitoringtypesNodeRecordDTO } from 'api/generated/services/sigNoz.schemas';
import {
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesNodeRecordDTO,
} from 'api/generated/services/sigNoz.schemas';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import ClickableStatusBadge from '../Base/components/StatusFilterCells/ClickableStatusBadge';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import {
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
import { TextNoData, ValidateColumnValueWrapper } from '../components';
import { InfraMonitoringEntity } from '../constants';
import { NODE_CONDITION_COLORS, NODE_CONDITION_LABELS } from './utils';
import { Workflow } from '@signozhq/icons';
export function getK8sNodeRowKey(
@@ -31,18 +32,6 @@ export function getK8sNodeItemKey(
return node.nodeName;
}
const NODE_CONDITION_COLORS: Record<string, BadgeColor> = {
ready: 'forest',
not_ready: 'amber',
no_data: 'secondary',
};
const NODE_CONDITION_LABEL_MAP: Record<string, string> = {
ready: 'Ready',
not_ready: 'Not Ready',
no_data: 'No Data',
};
export type NodeTableColumnConfig =
TableColumnDef<InframonitoringtypesNodeRecordDTO>;
export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
@@ -107,24 +96,31 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
}
return (
<Badge color={color} variant="outline">
{NODE_CONDITION_LABEL_MAP[row.condition] || 'Unknown'}
</Badge>
<ClickableStatusBadge
color={color}
label={NODE_CONDITION_LABELS[row.condition] || 'Unknown'}
status={row.condition}
kind="node"
rowId={rowId}
/>
);
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="node"
items={[
{
value: row.nodeCountsByReadiness?.ready ?? 0,
label: 'Ready',
color: Color.BG_FOREST_500,
statuses: [InframonitoringtypesNodeConditionDTO.ready],
},
{
value: row.nodeCountsByReadiness?.notReady ?? 0,
label: 'Not Ready',
color: Color.BG_AMBER_500,
statuses: [InframonitoringtypesNodeConditionDTO.not_ready],
},
]}
rowId={rowId}
@@ -149,7 +145,8 @@ export const k8sNodesColumnsConfig: NodeTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="pod"
items={getPodStatusItems(row.podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -0,0 +1,20 @@
import { BadgeColor } from '@signozhq/ui/badge';
import { InframonitoringtypesNodeConditionDTO } from 'api/generated/services/sigNoz.schemas';
export const NODE_CONDITION_COLORS: Record<
InframonitoringtypesNodeConditionDTO,
BadgeColor
> = {
[InframonitoringtypesNodeConditionDTO.ready]: 'forest',
[InframonitoringtypesNodeConditionDTO.not_ready]: 'amber',
[InframonitoringtypesNodeConditionDTO.no_data]: 'secondary',
};
export const NODE_CONDITION_LABELS: Record<
InframonitoringtypesNodeConditionDTO,
string
> = {
[InframonitoringtypesNodeConditionDTO.ready]: 'Ready',
[InframonitoringtypesNodeConditionDTO.not_ready]: 'Not Ready',
[InframonitoringtypesNodeConditionDTO.no_data]: 'No Data',
};

View File

@@ -36,7 +36,10 @@ async function fetchListData(
try {
const response = await listPods(
{
filter: { expression: filters.filter.expression },
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -1,5 +1,4 @@
import { Container } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import {
InframonitoringtypesPodRecordDTO,
InframonitoringtypesPodStatusDTO,
@@ -10,15 +9,17 @@ import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import ClickableStatusBadge from '../Base/components/StatusFilterCells/ClickableStatusBadge';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import {
formatBytes,
getPodStatusItems,
POD_STATUS_COLORS,
POD_STATUS_LABELS,
} from '../commonUtils';
import {
EntityProgressBar,
EntityProgressThresholds,
GroupedStatusCounts,
TextNoData,
ValidateColumnValueWrapper,
} from '../components';
@@ -100,7 +101,7 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
width: { min: 250 },
enableSort: false,
visibilityBehavior: 'hidden-on-expand',
cell: ({ row }): React.ReactNode => {
cell: ({ row, rowId }): React.ReactNode => {
if (!row.podStatus) {
return <></>;
}
@@ -110,11 +111,14 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
}
const color = POD_STATUS_COLORS[row.podStatus] || POD_STATUS_COLORS.unknown;
const label = row.podStatus.charAt(0).toUpperCase() + row.podStatus.slice(1);
return (
<Badge color={color} variant="outline">
{label}
</Badge>
<ClickableStatusBadge
color={color}
label={POD_STATUS_LABELS[row.podStatus]}
status={row.podStatus}
kind="pod"
rowId={rowId}
/>
);
},
},
@@ -136,7 +140,8 @@ export const k8sPodColumnsConfig: PodTableColumnConfig[] = [
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="pod"
items={getPodStatusItems(row.podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -42,7 +42,10 @@ async function fetchListData(
try {
const response = await listStatefulSets(
{
filter: { expression: filters.filter.expression },
filter: {
expression: filters.filter.expression,
filterByPodStatus: filters.filter.filterByPodStatus,
},
groupBy: filters.groupBy?.map((g) => ({ name: g.name })),
offset: filters.offset,
limit: filters.limit ?? 10,

View File

@@ -4,6 +4,7 @@ import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import EntityGroupHeader from '../Base/EntityGroupHeader';
import K8sGroupCell from '../Base/K8sGroupCell';
import { SelectedItemParams } from '../hooks';
@@ -127,7 +128,8 @@ export const k8sStatefulSetsColumnsConfig: TableColumnDef<InframonitoringtypesSt
return <TextNoData type="tanstack" />;
}
return (
<GroupedStatusCounts
<ClickableStatusCounts
kind="pod"
items={getPodStatusItems(podCountsByStatus)}
rowId={rowId}
/>

View File

@@ -69,6 +69,42 @@ function renderPage(
}
describe('InfraMonitoringK8s', () => {
describe('when the category changes with a status filter applied', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
beforeEach(async () => {
onUrlUpdateMock.mockClear();
renderPage(
{
category: K8sCategories.CLUSTERS,
podStatus: 'crashloopbackoff',
nodeReadiness: 'not_ready',
},
onUrlUpdateMock,
);
await screen.findByTestId(`category-${K8sCategories.CONTAINERS}`);
});
it('should drop the status filters the new category cannot apply', async () => {
fireEvent.click(screen.getByTestId(`category-${K8sCategories.CONTAINERS}`));
// Containers filter on their own statuses, so a pod status carried over from
// clusters would sit in the toolbar looking applied while matching nothing.
await waitFor(() => {
const categorySwitch = onUrlUpdateMock.mock.calls.find(
(call) =>
call[0].searchParams.get('category') === K8sCategories.CONTAINERS,
);
expect(categorySwitch).toBeDefined();
expect(categorySwitch?.[0].searchParams.get('podStatus')).toBeNull();
expect(categorySwitch?.[0].searchParams.get('nodeReadiness')).toBeNull();
});
});
});
describe('when the category changes from a page other than the first', () => {
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();

View File

@@ -0,0 +1,87 @@
import {
InframonitoringtypesContainerCountsByStatusDTO,
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesPodCountsByStatusDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPodStatusItems } from '../commonUtils';
import { getContainerStatusItems } from '../Containers/utils';
const POD_COUNT_KEYS: Array<keyof InframonitoringtypesPodCountsByStatusDTO> = [
'running',
'completed',
'pending',
'unknown',
'failed',
'crashLoopBackOff',
'imagePullBackOff',
'errImagePull',
'createContainerConfigError',
'containerCreating',
'oomKilled',
'error',
'containerCannotRun',
'evicted',
'nodeAffinity',
'nodeLost',
'shutdown',
'unexpectedAdmissionError',
];
function podCounts(): InframonitoringtypesPodCountsByStatusDTO {
return POD_COUNT_KEYS.reduce(
(acc, key) => ({ ...acc, [key]: 1 }),
{} as InframonitoringtypesPodCountsByStatusDTO,
);
}
function containerCounts(): InframonitoringtypesContainerCountsByStatusDTO {
return {
running: 1,
waiting: 1,
terminated: 1,
completed: 1,
unknown: 1,
containerCreating: 1,
crashLoopBackOff: 1,
imagePullBackOff: 1,
errImagePull: 1,
createContainerConfigError: 1,
oomKilled: 1,
error: 1,
containerCannotRun: 1,
} as InframonitoringtypesContainerCountsByStatusDTO;
}
describe('status counts carry filterable statuses', () => {
it('maps every pod count bucket to a real status enum member', () => {
const items = getPodStatusItems(podCounts());
const statuses = items.flatMap((item) => item.statuses ?? []);
const valid = Object.values(InframonitoringtypesPodStatusDTO) as string[];
expect(statuses.length).toBeGreaterThan(0);
statuses.forEach((status) => expect(valid).toContain(status));
});
it('gives the pod Error Status chip every error bucket it sums', () => {
const items = getPodStatusItems(podCounts());
const errorItem = items.find((item) => item.label === 'Error Status');
// The chip's own value is the sum of its breakdown, so selecting it must
// select every status counted in that sum.
expect(errorItem?.value).toBe(errorItem?.breakdown?.length);
expect(errorItem?.statuses).toHaveLength(errorItem?.breakdown?.length ?? 0);
});
it('maps every container count bucket to a real status enum member', () => {
const items = getContainerStatusItems(containerCounts());
const statuses = items.flatMap((item) => item.statuses ?? []);
const valid = Object.values(
InframonitoringtypesContainerStatusDTO,
) as string[];
expect(statuses.length).toBeGreaterThan(0);
statuses.forEach((status) => expect(valid).toContain(status));
});
});

View File

@@ -0,0 +1,68 @@
import * as inframonitoring from 'api/generated/services/inframonitoring';
import { entityRegistry } from '../Base/entity.registry';
import { K8sCategories } from '../constants';
jest.mock('api/generated/services/inframonitoring');
/**
* Every list the status filter offers must forward the flag, or the control is a
* no-op on that section. Mirrors the filter structs in pkg/types/inframonitoringtypes.
*/
const EXPECTED_FLAGS: Record<string, string[]> = {
[K8sCategories.PODS]: ['filterByPodStatus'],
[K8sCategories.CONTAINERS]: ['filterByContainerStatus'],
[K8sCategories.NODES]: ['filterByPodStatus', 'filterByNodeReadiness'],
[K8sCategories.CLUSTERS]: ['filterByPodStatus', 'filterByNodeReadiness'],
[K8sCategories.NAMESPACES]: ['filterByPodStatus'],
[K8sCategories.DEPLOYMENTS]: ['filterByPodStatus'],
[K8sCategories.DAEMONSETS]: ['filterByPodStatus'],
[K8sCategories.STATEFULSETS]: ['filterByPodStatus'],
[K8sCategories.JOBS]: ['filterByPodStatus'],
};
const SENTINELS: Record<string, string[]> = {
filterByPodStatus: ['crashloopbackoff'],
filterByNodeReadiness: ['not_ready'],
filterByContainerStatus: ['oomkilled'],
};
describe('status filters reach the list APIs', () => {
beforeEach(() => {
jest.clearAllMocks();
Object.values(inframonitoring).forEach((exported) => {
if (jest.isMockFunction(exported)) {
exported.mockResolvedValue({
data: { type: 'list', records: [], total: 0 },
});
}
});
});
Object.entries(EXPECTED_FLAGS).forEach(([category, flags]) => {
it(`forwards ${flags.join(' + ')} for ${category}`, async () => {
const config = entityRegistry[category];
expect(config).toBeDefined();
await config.list.fetchListData({
filter: {
expression: '',
...Object.fromEntries(flags.map((flag) => [flag, SENTINELS[flag]])),
},
start: 1,
end: 2,
limit: 10,
offset: 0,
});
const call = Object.values(inframonitoring)
.filter(jest.isMockFunction)
.flatMap((fn) => (fn as jest.Mock).mock.calls)[0];
expect(call).toBeDefined();
flags.forEach((flag) => {
expect(call[0].filter[flag]).toStrictEqual(SENTINELS[flag]);
});
});
});
});

View File

@@ -51,6 +51,34 @@ export const POD_STATUS_COLORS: Record<
[InframonitoringtypesPodStatusDTO.unexpectedadmissionerror]: 'cherry',
};
/** kubectl prints these as single CamelCase words, so the enum value alone is not a usable label. */
export const POD_STATUS_LABELS: Record<
InframonitoringtypesPodStatusDTO,
string
> = {
[InframonitoringtypesPodStatusDTO.running]: 'Running',
[InframonitoringtypesPodStatusDTO.completed]: 'Completed',
[InframonitoringtypesPodStatusDTO.pending]: 'Pending',
[InframonitoringtypesPodStatusDTO.unknown]: 'Unknown',
[InframonitoringtypesPodStatusDTO.no_data]: 'No data',
[InframonitoringtypesPodStatusDTO.failed]: 'Failed',
[InframonitoringtypesPodStatusDTO.crashloopbackoff]: 'CrashLoopBackOff',
[InframonitoringtypesPodStatusDTO.imagepullbackoff]: 'ImagePullBackOff',
[InframonitoringtypesPodStatusDTO.errimagepull]: 'ErrImagePull',
[InframonitoringtypesPodStatusDTO.createcontainerconfigerror]:
'CreateContainerConfigError',
[InframonitoringtypesPodStatusDTO.containercreating]: 'ContainerCreating',
[InframonitoringtypesPodStatusDTO.oomkilled]: 'OOMKilled',
[InframonitoringtypesPodStatusDTO.error]: 'Error',
[InframonitoringtypesPodStatusDTO.containercannotrun]: 'ContainerCannotRun',
[InframonitoringtypesPodStatusDTO.evicted]: 'Evicted',
[InframonitoringtypesPodStatusDTO.nodeaffinity]: 'NodeAffinity',
[InframonitoringtypesPodStatusDTO.nodelost]: 'NodeLost',
[InframonitoringtypesPodStatusDTO.shutdown]: 'Shutdown',
[InframonitoringtypesPodStatusDTO.unexpectedadmissionerror]:
'UnexpectedAdmissionError',
};
type PodStatusCategory =
| 'running'
| 'completed'
@@ -105,6 +133,24 @@ const ERROR_STATUS_LABELS: Record<ErrorStatusKey, string> = {
unexpectedAdmissionError: 'UnexpectedAdmissionError',
};
const POD_STATUS_VALUES = new Set<string>(
Object.values(InframonitoringtypesPodStatusDTO),
);
/**
* The count DTO spells each status in camelCase (`crashLoopBackOff`) where the
* status enum spells it lowercase (`crashloopbackoff`); anything that does not
* land on a real enum member is dropped rather than sent to the API.
*/
function countKeyToPodStatus(
key: string,
): InframonitoringtypesPodStatusDTO | null {
const value = key.toLowerCase();
return POD_STATUS_VALUES.has(value)
? (value as InframonitoringtypesPodStatusDTO)
: null;
}
export function getPodStatusItems(
counts: InframonitoringtypesPodCountsByStatusDTO,
): StatusCountItem[] {
@@ -114,18 +160,42 @@ export function getPodStatusItems(
const errorBreakdown = errorKeys.map((key) => ({
label: ERROR_STATUS_LABELS[key],
value: counts[key],
statuses: [countKeyToPodStatus(key)].filter(
(status): status is InframonitoringtypesPodStatusDTO => status !== null,
),
}));
return [
{ value: counts.running, label: 'Running', color: Color.BG_FOREST_500 },
{ value: counts.completed, label: 'Completed', color: Color.BG_ROBIN_500 },
{ value: counts.pending, label: 'Pending', color: Color.BG_AMBER_500 },
{ value: counts.unknown, label: 'Unknown', color: Color.BG_SLATE_400 },
{
value: counts.running,
label: 'Running',
color: Color.BG_FOREST_500,
statuses: [InframonitoringtypesPodStatusDTO.running],
},
{
value: counts.completed,
label: 'Completed',
color: Color.BG_ROBIN_500,
statuses: [InframonitoringtypesPodStatusDTO.completed],
},
{
value: counts.pending,
label: 'Pending',
color: Color.BG_AMBER_500,
statuses: [InframonitoringtypesPodStatusDTO.pending],
},
{
value: counts.unknown,
label: 'Unknown',
color: Color.BG_SLATE_400,
statuses: [InframonitoringtypesPodStatusDTO.unknown],
},
{
value: errorTotal,
label: 'Error Status',
color: Color.BG_CHERRY_500,
breakdown: errorBreakdown,
statuses: errorBreakdown.flatMap((item) => item.statuses),
},
];
}

View File

@@ -45,3 +45,25 @@
.tooltipValue {
font-variant-numeric: tabular-nums;
}
.itemSelectable {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.tooltipRowSelectable {
width: 100%;
background: none;
border: none;
padding: 0;
cursor: pointer;
font: inherit;
text-align: left;
&:hover {
text-decoration: underline;
}
}

View File

@@ -8,6 +8,8 @@ import { MouseEventHandler } from 'react';
export interface StatusBreakdownItem {
label: string;
value: number;
/** The status values this row stands for, for click-to-filter. */
statuses?: string[];
}
export interface StatusCountItem {
@@ -15,15 +17,22 @@ export interface StatusCountItem {
label: string;
color: string;
breakdown?: StatusBreakdownItem[];
/** The status values this count stands for, for click-to-filter. */
statuses?: string[];
}
interface GroupedStatusCountsProps {
items: StatusCountItem[];
rowId: string;
showZeroValues?: boolean;
/** Omit to leave the counts inert, as on entities with no status filter. */
onSelectStatuses?: (statuses: string[]) => void;
}
function buildTooltipContent(item: StatusCountItem): React.ReactNode {
function buildTooltipContent(
item: StatusCountItem,
onSelectStatuses?: (statuses: string[]) => void,
): React.ReactNode {
const onClickHandle: MouseEventHandler = (e) => {
e.preventDefault();
e.stopPropagation();
@@ -57,14 +66,33 @@ function buildTooltipContent(item: StatusCountItem): React.ReactNode {
<Typography.Text className={styles.tooltipHeader}>
{item.label}
</Typography.Text>
{nonZeroBreakdown.map((b) => (
<div key={b.label} className={styles.tooltipRow}>
<Typography.Text>{b.label}</Typography.Text>
<Typography.Text className={styles.tooltipValue}>
{b.value}
</Typography.Text>
</div>
))}
{nonZeroBreakdown.map((b) =>
onSelectStatuses && b.statuses?.length ? (
<button
key={b.label}
type="button"
className={`${styles.tooltipRow} ${styles.tooltipRowSelectable}`}
data-testid={`status-breakdown-${b.statuses.join('-')}`}
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
onSelectStatuses(b.statuses as string[]);
}}
>
<Typography.Text>{b.label}</Typography.Text>
<Typography.Text className={styles.tooltipValue}>
{b.value}
</Typography.Text>
</button>
) : (
<div key={b.label} className={styles.tooltipRow}>
<Typography.Text>{b.label}</Typography.Text>
<Typography.Text className={styles.tooltipValue}>
{b.value}
</Typography.Text>
</div>
),
)}
</div>
);
}
@@ -73,6 +101,7 @@ export function GroupedStatusCounts({
items,
rowId,
showZeroValues = true,
onSelectStatuses,
}: GroupedStatusCountsProps): JSX.Element {
const visibleItems =
showZeroValues === false ? items.filter((item) => item.value > 0) : items;
@@ -87,14 +116,30 @@ export function GroupedStatusCounts({
<TanStackTable.HoverTooltip
key={item.label}
rowId={rowId}
title={buildTooltipContent(item)}
title={buildTooltipContent(item, onSelectStatuses)}
arrow
align="start"
>
{item.value ? (
<TanStackTable.Text
className={styles.item}
className={`${styles.item} ${
onSelectStatuses && item.statuses?.length ? styles.itemSelectable : ''
}`}
style={{ '--gsc-color': item.color } as React.CSSProperties}
data-testid={
item.statuses?.length
? `status-count-${item.statuses.join('-')}`
: undefined
}
onClick={
onSelectStatuses && item.statuses?.length
? (e: React.MouseEvent): void => {
e.preventDefault();
e.stopPropagation();
onSelectStatuses(item.statuses as string[]);
}
: undefined
}
>
{item.value}
</TanStackTable.Text>

View File

@@ -971,6 +971,9 @@ export const INFRA_MONITORING_K8S_PARAMS_KEYS = {
EVENTS_FILTERS: 'eventsFilters',
HOSTS_FILTERS: 'hostsFilters',
STATUS_FILTER: 'statusFilter',
POD_STATUS_FILTER: 'podStatus',
NODE_READINESS_FILTER: 'nodeReadiness',
CONTAINER_STATUS_FILTER: 'containerStatus',
CURRENT_PAGE: 'currentPage',
PAGE: 'page',
PAGE_SIZE: 'pageSize',

View File

@@ -1,13 +1,20 @@
import {
createParser,
Options,
parseAsArrayOf,
parseAsInteger,
parseAsJson,
parseAsString,
parseAsStringEnum,
useQueryState,
useQueryStates,
UseQueryStateReturn,
} from 'nuqs';
import {
InframonitoringtypesContainerStatusDTO,
InframonitoringtypesNodeConditionDTO,
InframonitoringtypesPodStatusDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useCallback, useMemo } from 'react';
import {
IBuilderQuery,
@@ -225,3 +232,58 @@ export const useInfraMonitoringStatusFilter = (): UseQueryStateReturn<
INFRA_MONITORING_K8S_PARAMS_KEYS.STATUS_FILTER,
parseAsString.withDefault('').withOptions(defaultNuqsOptions),
);
/**
* The list APIs reject the `no_data` sentinel (see IsFilterable in
* pkg/types/inframonitoringtypes), so it is never offered as a filter value.
*/
function withoutNoData<T extends string>(values: T[]): T[] {
return values.filter((value) => value !== 'no_data');
}
export const FILTERABLE_POD_STATUSES = withoutNoData(
Object.values(InframonitoringtypesPodStatusDTO),
);
export const FILTERABLE_NODE_CONDITIONS = withoutNoData(
Object.values(InframonitoringtypesNodeConditionDTO),
);
export const FILTERABLE_CONTAINER_STATUSES = withoutNoData(
Object.values(InframonitoringtypesContainerStatusDTO),
);
const EMPTY_FILTER: [] = [];
export const useInfraMonitoringPodStatusFilter = (): UseQueryStateReturn<
InframonitoringtypesPodStatusDTO[],
[]
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.POD_STATUS_FILTER,
parseAsArrayOf(parseAsStringEnum(FILTERABLE_POD_STATUSES))
.withDefault(EMPTY_FILTER)
.withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringNodeReadinessFilter = (): UseQueryStateReturn<
InframonitoringtypesNodeConditionDTO[],
[]
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.NODE_READINESS_FILTER,
parseAsArrayOf(parseAsStringEnum(FILTERABLE_NODE_CONDITIONS))
.withDefault(EMPTY_FILTER)
.withOptions(defaultNuqsOptions),
);
export const useInfraMonitoringContainerStatusFilter = (): UseQueryStateReturn<
InframonitoringtypesContainerStatusDTO[],
[]
> =>
useQueryState(
INFRA_MONITORING_K8S_PARAMS_KEYS.CONTAINER_STATUS_FILTER,
parseAsArrayOf(parseAsStringEnum(FILTERABLE_CONTAINER_STATUSES))
.withDefault(EMPTY_FILTER)
.withOptions(defaultNuqsOptions),
);

View File

@@ -41,7 +41,7 @@ import { buildCompositeKey, getOptionsFromKeys } from './utils';
interface UseOptionsMenuProps {
storageKey?: string;
dataSource: DataSource;
aggregateOperator: string;
aggregateOperator?: string;
initialOptions?: InitialOptions;
}

View File

@@ -0,0 +1,34 @@
import { useMemo } from 'react';
import { Check } from '@signozhq/icons';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import SavedViewsIconButton from './SavedViewsIconButton';
import { SaveChangesMenuProps } from './types';
function SaveChangesMenu({
disabled,
onSaveAsNew,
onUpdate,
}: SaveChangesMenuProps): JSX.Element {
const items = useMemo<MenuItem[]>(
() => [
{ key: 'save-as-new', label: 'Save as new view', onClick: onSaveAsNew },
{ key: 'update', label: 'Update selected view', onClick: onUpdate },
],
[onSaveAsNew, onUpdate],
);
return (
<DropdownMenuSimple menu={{ items }} align="end">
<SavedViewsIconButton
title="Save changes"
icon={<Check size={14} />}
color="warning"
disabled={disabled}
testId="saved-views-save-changes"
/>
</DropdownMenuSimple>
);
}
export default SaveChangesMenu;

View File

@@ -0,0 +1,18 @@
.field {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.label {
font-size: var(--font-size-xs);
color: var(--l1-foreground);
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-4);
width: 100%;
}

View File

@@ -0,0 +1,94 @@
import { FormEvent, useState } from 'react';
import { Check, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { SAVE_VIEW_MODAL_TITLE } from './constants';
import { SaveViewModalProps } from './types';
import styles from './SaveViewModal.module.scss';
const NAME_INPUT_ID = 'save-view-name';
function SaveViewModal({
mode,
isSaving,
onClose,
onSave,
}: SaveViewModalProps): JSX.Element {
const [name, setName] = useState('');
const displayName = name.trim();
const handleSave = async (): Promise<void> => {
if (!displayName || isSaving) {
return;
}
if (await onSave(displayName)) {
onClose();
}
};
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
void handleSave();
};
return (
<DialogWrapper
open
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={SAVE_VIEW_MODAL_TITLE[mode]}
testId="save-view-modal"
footer={
<div className={styles.footer}>
<Button
variant="outlined"
color="secondary"
size="md"
prefix={<X size={14} />}
onClick={onClose}
testId="save-view-cancel"
>
Cancel
</Button>
<Button
color="primary"
size="md"
prefix={<Check size={14} />}
loading={isSaving}
disabled={!displayName}
onClick={(): void => {
void handleSave();
}}
testId="save-view-submit"
>
Save view
</Button>
</div>
}
>
<form className={styles.field} onSubmit={handleSubmit}>
<label htmlFor={NAME_INPUT_ID}>
<Typography.Text className={styles.label}>Enter view name</Typography.Text>
</label>
<Input
id={NAME_INPUT_ID}
placeholder="Eg. payments-view"
value={name}
onChange={(event): void => setName(event.target.value)}
autoFocus
autoComplete="off"
testId="save-view-name"
/>
</form>
</DialogWrapper>
);
}
export default SaveViewModal;

View File

@@ -3,17 +3,59 @@
align-items: center;
justify-content: space-between;
gap: var(--spacing-2);
padding: var(--spacing-2) var(--spacing-3);
padding: var(--spacing-2) var(--spacing-5);
border-bottom: 1px solid var(--l1-border);
color: var(--l1-foreground);
flex-shrink: 0;
&.isDirty {
background: linear-gradient(
to right,
var(--callout-warning-background),
transparent
);
}
}
.name {
display: inline-flex;
align-items: center;
gap: var(--spacing-3);
min-width: 0;
padding: var(--spacing-1) var(--spacing-4);
border-radius: var(--spacing-1);
background: var(--l3-background);
color: var(--l1-foreground);
font-size: var(--font-size-sm);
&.isDirty {
background: var(--callout-warning-background);
color: var(--callout-warning-title);
}
}
.nameText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
.dot {
width: var(--spacing-3);
height: var(--spacing-3);
flex-shrink: 0;
border-radius: 50%;
background: var(--callout-warning-icon);
}
.skeleton {
flex: 1;
min-width: 0;
}
.actions {
display: flex;
align-items: center;
flex-shrink: 0;
gap: var(--spacing-1);
}

View File

@@ -1,39 +1,140 @@
import { Menu } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { useCallback, useState } from 'react';
import cx from 'classnames';
import { Menu, Plus, Undo2, X } from '@signozhq/icons';
import { Skeleton } from '@signozhq/ui/skeleton';
import { toast } from '@signozhq/ui/sonner';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './SavedViewsHeader.module.scss';
import { MY_VIEW_NAME, SAVED_VIEW_TOAST_POSITION } from './constants';
import { useActiveSavedView } from './hooks/useActiveSavedView';
import { useSavedViewActions } from './hooks/useSavedViewActions';
import SaveChangesMenu from './SaveChangesMenu';
import SavedViewsIconButton from './SavedViewsIconButton';
import SaveViewModal from './SaveViewModal';
import { SaveViewModalMode } from './types';
// Temp Placeholder data till main functionality is plugged in
const PLACEHOLDER_VIEW_NAME = 'My view';
import styles from './SavedViewsHeader.module.scss';
function SavedViewsHeader({
source,
onOpenViews,
}: {
source: SavedviewtypesSourceDTO;
// Absent when the list is always on screen.
// Absent while the list is on screen.
onOpenViews?: () => void;
}): JSX.Element {
const { view, isLoading, hasUnsavedChanges } = useActiveSavedView(source);
const { revertView, clearView, createView, updateView, isSaving } =
useSavedViewActions(source);
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
const handleSave = useCallback(
async (displayName: string): Promise<boolean> => {
const id = await createView(displayName);
if (!id) {
return false;
}
toast.success('You have created a new view.', {
position: SAVED_VIEW_TOAST_POSITION,
...(onOpenViews && { action: { label: 'View', onClick: onOpenViews } }),
});
return true;
},
[createView, onOpenViews],
);
const handleUpdate = useCallback(async (): Promise<void> => {
if (view && (await updateView(view))) {
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
}
}, [view, updateView]);
const openSaveAsNew = useCallback((): void => setModalMode('saveAsNew'), []);
const closeModal = useCallback((): void => setModalMode(null), []);
const name = view?.spec.displayName ?? MY_VIEW_NAME;
const isDirty = !!view && hasUnsavedChanges;
return (
<div
className={styles.header}
className={cx(styles.header, { [styles.isDirty]: isDirty })}
data-testid="saved-views-header"
data-source={source}
>
<Typography.Text className={styles.name}>
{PLACEHOLDER_VIEW_NAME}
</Typography.Text>
{onOpenViews && (
<Button
variant="ghost"
color="secondary"
size="icon"
aria-label="All views"
prefix={<Menu size={14} />}
onClick={onOpenViews}
data-testid="saved-views-open"
{isLoading ? (
<Skeleton.Input
active
size="small"
className={styles.skeleton}
testId="saved-views-loading"
/>
) : (
<TooltipSimple title={name}>
<span
className={cx(styles.name, { [styles.isDirty]: isDirty })}
data-testid="saved-views-name"
>
{isDirty && <span className={styles.dot} />}
<span className={styles.nameText}>{name}</span>
</span>
</TooltipSimple>
)}
<div className={styles.actions}>
{!view && (
<SavedViewsIconButton
title="Create new view"
icon={<Plus size={14} />}
disabled={isLoading}
onClick={(): void => setModalMode('create')}
testId="saved-views-create"
/>
)}
{view && !isDirty && (
<SavedViewsIconButton
title="Clear view"
icon={<Undo2 size={14} />}
onClick={clearView}
testId="saved-views-clear"
/>
)}
{view && isDirty && (
<>
<SaveChangesMenu
disabled={isSaving}
onSaveAsNew={openSaveAsNew}
onUpdate={(): void => {
void handleUpdate();
}}
/>
<SavedViewsIconButton
title="Discard changes"
icon={<X size={14} />}
color="warning"
disabled={isSaving}
onClick={(): void => revertView(view)}
testId="saved-views-discard"
/>
</>
)}
{!isDirty && onOpenViews && (
<SavedViewsIconButton
title="All views"
icon={<Menu size={14} />}
onClick={onOpenViews}
testId="saved-views-open"
/>
)}
</div>
{modalMode && (
<SaveViewModal
mode={modalMode}
isSaving={isSaving}
onClose={closeModal}
onSave={handleSave}
/>
)}
</div>

View File

@@ -0,0 +1,41 @@
import { forwardRef } from 'react';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { SavedViewsIconButtonProps } from './types';
// forwardRef and rest props so a menu can use it as its trigger.
const SavedViewsIconButton = forwardRef<
HTMLButtonElement,
SavedViewsIconButtonProps
>(function SavedViewsIconButton(
{
title,
icon,
testId,
color = 'secondary',
disabled,
onClick,
...triggerProps
},
ref,
): JSX.Element {
return (
<TooltipSimple title={title}>
<Button
ref={ref}
variant="ghost"
color={color}
size="icon"
aria-label={title}
prefix={icon}
disabled={disabled}
onClick={onClick}
{...triggerProps}
data-testid={testId}
/>
</TooltipSimple>
);
});
export default SavedViewsIconButton;

View File

@@ -0,0 +1,17 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useRestoreLastUsedView } from './hooks/useRestoreLastUsedView';
import { useSavedViewActions } from './hooks/useSavedViewActions';
// Outside the sidebar, so a collapsed sidebar still restores on a bare explorer.
function SavedViewsRestore({
source,
}: {
source: SavedviewtypesSourceDTO;
}): null {
const { selectView } = useSavedViewActions(source);
useRestoreLastUsedView({ source, selectView });
return null;
}
export default SavedViewsRestore;

View File

@@ -0,0 +1,211 @@
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import SavedViewsHeader from '../SavedViewsHeader';
import {
explorerUrl,
makeView,
mockSavedViewsApi,
queryWith,
renderWithExplorerProviders,
urlParam,
viewUrl,
} from './savedViewsTestUtils';
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
const PATH = ROUTES.TRACES_EXPLORER;
function renderHeader(
url: string,
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
/>,
url,
);
}
const modal = (): HTMLElement => screen.getByTestId('save-view-modal');
const isModalOpen = (): boolean => !!screen.queryByTestId('save-view-modal');
async function openDirtyView(
view: ReturnType<typeof makeView>,
): Promise<ReturnType<typeof renderWithExplorerProviders>> {
const rendered = renderHeader(viewUrl(PATH, view));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
view.spec.displayName,
),
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
);
act(() => {
rendered.history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: view.id,
}),
);
});
await waitFor(() =>
expect(screen.getByTestId('saved-views-discard')).toBeInTheDocument(),
);
return rendered;
}
async function chooseFromSaveMenu(item: string): Promise<void> {
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
await userEvent.click(await screen.findByRole('menuitem', { name: item }));
}
async function submitName(name: string): Promise<void> {
await userEvent.type(screen.getByTestId('save-view-name'), name);
await userEvent.click(screen.getByTestId('save-view-submit'));
}
describe('SavedViewsHeader saving', () => {
beforeEach(() => {
localStorage.clear();
});
describe('create', () => {
it('saves the current state under a generated name and opens it', async () => {
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
const { history } = renderHeader(explorerUrl(PATH, {}));
await userEvent.click(screen.getByTestId('saved-views-create'));
expect(within(modal()).getByText('Create new view')).toBeInTheDocument();
expect(screen.getByTestId('save-view-submit')).toBeDisabled();
await submitName('Slow checkout');
await waitFor(() => expect(requests.created).toHaveLength(1));
expect(requests.created[0]).toStrictEqual(
expect.objectContaining({
generateName: true,
source: SavedviewtypesSourceDTO.traces,
schemaVersion: 'v2',
spec: expect.objectContaining({
displayName: 'Slow checkout',
panelType: 'list',
}),
}),
);
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify('view-new'),
),
);
expect(toast.success).toHaveBeenCalledWith(
'You have created a new view.',
expect.objectContaining({ position: 'top-right' }),
);
await waitFor(() => expect(isModalOpen()).toBe(false));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
'Slow checkout',
),
);
});
it('keeps the modal open and reports a failed save', async () => {
const requests = mockSavedViewsApi([], { createStatus: 500 });
const { history } = renderHeader(explorerUrl(PATH, {}));
await userEvent.click(screen.getByTestId('saved-views-create'));
await submitName('Slow checkout');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(requests.created).toHaveLength(1);
expect(isModalOpen()).toBe(true);
expect(screen.getByTestId('save-view-name')).toHaveValue('Slow checkout');
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
expect(toast.success).not.toHaveBeenCalled();
});
it('cancel closes the modal without saving', async () => {
const requests = mockSavedViewsApi([]);
renderHeader(explorerUrl(PATH, {}));
await userEvent.click(screen.getByTestId('saved-views-create'));
await userEvent.type(screen.getByTestId('save-view-name'), 'Draft');
await userEvent.click(screen.getByTestId('save-view-cancel'));
await waitFor(() => expect(isModalOpen()).toBe(false));
expect(requests.created).toHaveLength(0);
});
});
it('save as new creates another view from the changed state', async () => {
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const requests = mockSavedViewsApi([errors], { createdId: 'view-copy' });
const { history } = await openDirtyView(errors);
await chooseFromSaveMenu('Save as new view');
expect(within(modal()).getByText('Save as new view')).toBeInTheDocument();
await submitName('Errors copy');
await waitFor(() => expect(requests.created).toHaveLength(1));
const body = requests.created[0] as {
spec: {
displayName: string;
queries: { spec: { filter: { expression: string } } }[];
};
};
expect(body.spec.displayName).toBe('Errors copy');
expect(body.spec.queries[0].spec.filter.expression).toBe('has_error = false');
expect(requests.updated).toHaveLength(0);
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify('view-copy'),
),
);
});
it('update stores the changed state on the open view, keeping its name', async () => {
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const requests = mockSavedViewsApi([errors]);
await openDirtyView(errors);
await chooseFromSaveMenu('Update selected view');
await waitFor(() => expect(requests.updated).toHaveLength(1));
const { id, body } = requests.updated[0] as {
id: string;
body: {
source: string;
spec: {
displayName: string;
queries: { spec: { filter: { expression: string } } }[];
};
};
};
expect(id).toBe('view-1');
expect(body.source).toBe(SavedviewtypesSourceDTO.traces);
expect(body.spec.displayName).toBe('Errors');
expect(body.spec.queries[0].spec.filter.expression).toBe('has_error = false');
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith(
'View updated',
expect.objectContaining({ position: 'top-right' }),
),
);
expect(requests.created).toHaveLength(0);
});
});

View File

@@ -0,0 +1,327 @@
import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DataSource } from 'types/common/queryBuilder';
import { useActiveSavedView } from '../hooks/useActiveSavedView';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsRestore from '../SavedViewsRestore';
import {
explorerUrl,
makeView,
mockSavedViewsApi,
queryWith,
renderWithExplorerProviders,
urlParam,
viewUrl,
} from './savedViewsTestUtils';
const PATH = ROUTES.TRACES_EXPLORER;
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const slow = makeView({
id: 'view-2',
displayName: 'Slow spans',
expression: 'duration_nano > 1000',
panelType: PANEL_TYPES.TIME_SERIES,
});
function renderHeader(
url: string,
onOpenViews: (() => void) | undefined = jest.fn(),
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<>
<SavedViewsRestore source={SavedviewtypesSourceDTO.traces} />
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={onOpenViews}
/>
<RenderRecorder />
</>,
url,
);
}
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
const isDirty = (): boolean => !!screen.queryByTestId('saved-views-discard');
const saveChanges = (): HTMLElement =>
screen.getByTestId('saved-views-save-changes');
async function waitForView(name: string): Promise<void> {
await waitFor(() => expect(chip()).toHaveTextContent(name));
await waitFor(() =>
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
);
}
// Each render's answer from the hook the header reads, so a state that lasts a
// single render is seen too.
interface RenderRecord {
viewId?: string;
dirty: boolean;
// The staged query had not caught up with the url yet.
isStaging: boolean;
}
let renders: RenderRecord[] = [];
function RenderRecorder(): null {
const { view, hasUnsavedChanges } = useActiveSavedView(
SavedviewtypesSourceDTO.traces,
);
const { stagedQuery } = useQueryBuilder();
const compositeQuery = useGetCompositeQueryParam();
renders.push({
viewId: view?.id,
dirty: hasUnsavedChanges,
isStaging: stagedQuery?.id !== compositeQuery?.id,
});
return null;
}
async function recordRenders(change: () => void): Promise<RenderRecord[]> {
renders = [];
act(change);
await waitFor(() =>
expect(screen.queryByTestId('saved-views-loading')).toBeNull(),
);
await act(() => new Promise((resolve) => setTimeout(resolve, 100)));
return renders;
}
describe('SavedViewsHeader', () => {
beforeEach(() => {
localStorage.clear();
});
it('shows My view with create and all views when no view is open', async () => {
mockSavedViewsApi([errors]);
renderHeader(explorerUrl(PATH, {}));
expect(chip()).toHaveTextContent('My view');
expect(screen.getByTestId('saved-views-create')).toBeInTheDocument();
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-clear')).toBeNull();
expect(screen.queryByTestId('saved-views-save-changes')).toBeNull();
expect(screen.queryByTestId('saved-views-discard')).toBeNull();
});
it('shows a skeleton while the view in the url loads', async () => {
mockSavedViewsApi([errors], { getDelay: 150 });
renderHeader(viewUrl(PATH, errors));
expect(screen.getByTestId('saved-views-loading')).toBeInTheDocument();
await waitForView('Errors');
expect(screen.queryByTestId('saved-views-loading')).toBeNull();
});
it('shows the open view clean, with clear and all views', async () => {
mockSavedViewsApi([errors]);
renderHeader(viewUrl(PATH, errors));
await waitForView('Errors');
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-create')).toBeNull();
expect(isDirty()).toBe(false);
});
it('falls back to My view when the view in the url does not load', async () => {
mockSavedViewsApi([]);
renderHeader(viewUrl(PATH, errors));
await waitFor(() =>
expect(screen.queryByTestId('saved-views-loading')).toBeNull(),
);
expect(chip()).toHaveTextContent('My view');
});
it('marks the view dirty once the query changes, swapping in save and discard', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(viewUrl(PATH, errors));
await waitForView('Errors');
act(() => {
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
);
});
await waitFor(() => expect(isDirty()).toBe(true));
expect(saveChanges()).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-clear')).toBeNull();
expect(screen.queryByTestId('saved-views-open')).toBeNull();
});
it('marks the view dirty when only the tab changes', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(viewUrl(PATH, errors));
await waitForView('Errors');
act(() => {
const params = new URLSearchParams(history.location.search);
params.set(QueryParams.panelTypes, JSON.stringify(PANEL_TYPES.TABLE));
history.push(`${PATH}?${params.toString()}`);
});
await waitFor(() => expect(isDirty()).toBe(true));
});
it('discard puts the saved query and tab back', async () => {
mockSavedViewsApi([slow]);
const { history } = renderHeader(viewUrl(PATH, slow));
await waitForView('Slow spans');
act(() => {
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = true'),
panelType: PANEL_TYPES.LIST,
viewKey: slow.id,
}),
);
});
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('saved-views-discard'));
await waitFor(() => expect(isDirty()).toBe(false));
expect(urlParam(history, QueryParams.panelTypes)).toBe(
JSON.stringify(PANEL_TYPES.TIME_SERIES),
);
expect(urlParam(history, QueryParams.viewKey)).toBe(JSON.stringify(slow.id));
expect(urlParam(history, QueryParams.viewName)).toBeNull();
const query = JSON.parse(
decodeURIComponent(urlParam(history, QueryParams.compositeQuery) ?? ''),
);
expect(query.builder.queryData[0].filter.expression).toBe(
'duration_nano > 1000',
);
});
it('clear drops the view from the url but keeps the time range', async () => {
localStorage.setItem(
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
JSON.stringify({ traces: { key: errors.id, value: 'Errors' } }),
);
mockSavedViewsApi([errors]);
const { history } = renderHeader(viewUrl(PATH, errors, '1h'));
await waitForView('Errors');
await userEvent.click(screen.getByTestId('saved-views-clear'));
await waitFor(() => expect(chip()).toHaveTextContent('My view'));
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
expect(urlParam(history, QueryParams.panelTypes)).toBeNull();
expect(urlParam(history, QueryParams.relativeTime)).toBe('1h');
expect(
JSON.parse(localStorage.getItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) ?? '{}'),
).toStrictEqual({});
});
describe('last used view', () => {
beforeEach(() => {
localStorage.setItem(
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
JSON.stringify({ traces: { key: errors.id, value: 'Errors' } }),
);
});
it('reopens on a bare explorer', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(explorerUrl(PATH, {}));
await waitForView('Errors');
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify(errors.id),
);
});
it('leaves a url with its own query alone', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'service.name = "cart"'),
}),
);
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
expect(chip()).toHaveTextContent('My view');
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
});
});
describe('unsaved changes indicator', () => {
it('never flashes while switching between views already loaded', async () => {
mockSavedViewsApi([errors, slow]);
const { history } = renderHeader(viewUrl(PATH, slow));
await waitForView('Slow spans');
act(() => history.push(viewUrl(PATH, errors)));
await waitForView('Errors');
const states = await recordRenders(() => history.push(viewUrl(PATH, slow)));
expect(chip()).toHaveTextContent('Slow spans');
expect(states.some((render) => render.isStaging)).toBe(true);
expect(states.filter((render) => render.dirty)).toStrictEqual([]);
});
it('holds while the query keeps changing on a dirty view', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(viewUrl(PATH, errors));
await waitForView('Errors');
act(() => {
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
);
});
await waitFor(() => expect(isDirty()).toBe(true));
const states = await recordRenders(() =>
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'duration_nano > 5'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
),
);
expect(states.some((render) => render.isStaging)).toBe(true);
expect(states.filter((render) => !render.dirty)).toStrictEqual([]);
});
});
it('on metrics compares no columns and has no all views button', async () => {
const cpu = makeView({
id: 'view-3',
displayName: 'CPU',
source: SavedviewtypesSourceDTO.metrics,
dataSource: DataSource.METRICS,
expression: '',
panelType: PANEL_TYPES.TIME_SERIES,
});
mockSavedViewsApi([cpu]);
renderWithExplorerProviders(
<SavedViewsHeader source={SavedviewtypesSourceDTO.metrics} />,
viewUrl(ROUTES.METRICS_EXPLORER_EXPLORER, cpu),
DataSource.METRICS,
);
await waitForView('CPU');
expect(isDirty()).toBe(false);
expect(screen.queryByTestId('saved-views-open')).toBeNull();
});
});

View File

@@ -0,0 +1,304 @@
import { ReactElement, useEffect, useMemo } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat';
import { render, RenderResult } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { safeNavigateMock } from '__tests__/safeNavigateMock';
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSavedViewSpecDTO,
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { createMemoryHistory, MemoryHistory } from 'history';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { NuqsAdapter } from 'nuqs/adapters/react';
import { AppContext } from 'providers/App/App';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import store from 'store';
import { getAppContextMock } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { __setSearchParamsGetterForTest } from 'utils/getUnstableCurrentSearchParams';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
export const API = 'http://localhost/api/v2/saved_views';
// The server stores queries through typed structs: nulls, empty strings,
// zeros and empty lists do not come back.
function asStoredByServer(value: unknown): unknown {
if (Array.isArray(value)) {
const items = value.map(asStoredByServer).filter((v) => v !== undefined);
return items.length > 0 ? items : undefined;
}
if (value !== null && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value)
.map(([key, v]) => [key, asStoredByServer(v)] as const)
.filter(([, v]) => v !== undefined),
);
}
return value === null || value === '' || value === 0 ? undefined : value;
}
export function queryWith(dataSource: DataSource, expression: string): Query {
const base = initialQueriesMap[dataSource];
return {
...base,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
filter: { expression },
aggregations: [{ expression: 'count()' }],
},
],
},
} as Query;
}
export function makeView({
id,
displayName,
source = SavedviewtypesSourceDTO.traces,
dataSource = DataSource.TRACES,
expression = 'has_error = true',
panelType = PANEL_TYPES.LIST,
}: {
id: string;
displayName: string;
source?: SavedviewtypesSourceDTO;
dataSource?: DataSource;
expression?: string;
panelType?: PANEL_TYPES;
}): SavedviewtypesSavedViewDTO {
const spec = toSavedViewSpec({
query: queryWith(dataSource, expression),
panelType,
displayName,
});
return {
id,
name: `${displayName.toLowerCase()}-abc`,
source,
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
createdBy: 'test@signoz.io',
updatedBy: 'test@signoz.io',
spec: {
...spec,
queries: asStoredByServer(
JSON.parse(JSON.stringify(spec.queries)),
) as SavedviewtypesSavedViewSpecDTO['queries'],
},
} as SavedviewtypesSavedViewDTO;
}
// Params written the way the query builder redirect writes them.
export function explorerUrl(
path: string,
{
query,
panelType,
viewKey,
relativeTime = '15m',
}: {
query?: Query;
panelType?: PANEL_TYPES;
viewKey?: string;
relativeTime?: string;
},
): string {
const params = new URLSearchParams();
if (query) {
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify({ ...query, id: `url-${Math.random()}` })),
);
}
if (panelType) {
params.set(QueryParams.panelTypes, JSON.stringify(panelType));
}
if (viewKey) {
params.set(QueryParams.viewKey, JSON.stringify(viewKey));
}
params.set(QueryParams.relativeTime, relativeTime);
return `${path}?${params.toString()}`;
}
export function viewUrl(
path: string,
view: SavedviewtypesSavedViewDTO,
relativeTime?: string,
): string {
return explorerUrl(path, {
query: getSavedViewQuery(view),
panelType: view.spec.panelType as unknown as PANEL_TYPES,
viewKey: view.id,
relativeTime,
});
}
export interface Requests {
created: unknown[];
updated: { id: string; body: unknown }[];
}
export function mockSavedViewsApi(
views: SavedviewtypesSavedViewDTO[],
{
createStatus = 201,
createdId = 'view-new',
getDelay = 0,
}: { createStatus?: number; createdId?: string; getDelay?: number } = {},
): Requests {
const requests: Requests = { created: [], updated: [] };
server.use(
rest.get(API, (req, res, ctx) => {
const source = req.url.searchParams.get('source');
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: views.filter((view) => !source || view.source === source),
}),
);
}),
rest.get(`${API}/:id`, (req, res, ctx) => {
const view = views.find((v) => v.id === req.params.id);
if (!view) {
return res(ctx.status(404), ctx.json({ status: 'error' }));
}
return res(
ctx.delay(getDelay),
ctx.status(200),
ctx.json({ status: 'success', data: view }),
);
}),
rest.post(API, async (req, res, ctx) => {
const body = await req.json();
requests.created.push(body);
if (createStatus >= 400) {
return res(
ctx.status(createStatus),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'boom' },
}),
);
}
views.push({ ...body, id: createdId } as SavedviewtypesSavedViewDTO);
return res(
ctx.status(createStatus),
ctx.json({ status: 'success', data: { id: createdId } }),
);
}),
rest.put(`${API}/:id`, async (req, res, ctx) => {
const body = await req.json();
requests.updated.push({ id: String(req.params.id), body });
return res(ctx.status(204));
}),
);
return requests;
}
const mockStore = configureStore([thunk]);
// What the explorer page does around the header: writes its default query to a
// bare url, and sets the provider's tab from the url (the provider alone seeds
// it from the raw, still JSON-quoted param).
function ExplorerShell({ dataSource }: { dataSource: DataSource }): null {
const panelType = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const { handleSetConfig, updateAllQueriesOperators } = useQueryBuilder();
const defaultQuery = useMemo(
() =>
updateAllQueriesOperators(
initialQueriesMap[dataSource],
PANEL_TYPES.LIST,
dataSource,
),
[updateAllQueriesOperators, dataSource],
);
useShareBuilderUrl({ defaultValue: defaultQuery });
useEffect(() => {
handleSetConfig(panelType, dataSource);
}, [panelType, dataSource, handleSetConfig]);
return null;
}
export function renderWithExplorerProviders(
ui: ReactElement,
initialUrl: string,
dataSource: DataSource = DataSource.TRACES,
): RenderResult & { history: MemoryHistory } {
const history = createMemoryHistory({ initialEntries: [initialUrl] });
// Parts of the explorer read window.location directly, so it follows the
// router's history.
const syncWindowLocation = (): void =>
window.history.replaceState(
null,
'',
`${history.location.pathname}${history.location.search}`,
);
syncWindowLocation();
history.listen(syncWindowLocation);
__setSearchParamsGetterForTest(
() => new URLSearchParams(history.location.search),
);
safeNavigateMock.mockImplementation((to) => {
history.push(
typeof to === 'string'
? to
: `${to.pathname ?? history.location.pathname}${to.search ?? ''}`,
);
});
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false, retry: false },
mutations: { retry: false },
},
});
const result = render(
<Router history={history}>
<CompatRouter>
<NuqsAdapter>
<QueryClientProvider client={queryClient}>
<Provider store={mockStore(store.getState())}>
<AppContext.Provider value={getAppContextMock('ADMIN')}>
<TooltipProvider>
<PreferenceContextProvider>
<QueryBuilderProvider>
<ExplorerShell dataSource={dataSource} />
{ui}
</QueryBuilderProvider>
</PreferenceContextProvider>
</TooltipProvider>
</AppContext.Provider>
</Provider>
</QueryClientProvider>
</NuqsAdapter>
</CompatRouter>
</Router>,
);
return { ...result, history };
}
export function urlParam(history: MemoryHistory, key: string): string | null {
return new URLSearchParams(history.location.search).get(key);
}

View File

@@ -0,0 +1,35 @@
import {
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { DataSource } from 'types/common/queryBuilder';
import { SaveViewModalMode } from './types';
export const SAVED_VIEW_SCHEMA_VERSION = SavedviewtypesSchemaVersionDTO.v2;
// Only the logs and traces explorers have columns and formatting to save.
export const SAVED_VIEW_OPTIONS_DATA_SOURCE: Partial<
Record<SavedviewtypesSourceDTO, DataSource>
> = {
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
};
// What selecting a view writes to the url, so clearing it removes exactly these.
export const SAVED_VIEW_URL_PARAMS = [
QueryParams.viewKey,
QueryParams.viewName,
QueryParams.compositeQuery,
QueryParams.panelTypes,
];
export const MY_VIEW_NAME = 'My view';
export const SAVE_VIEW_MODAL_TITLE: Record<SaveViewModalMode, string> = {
create: 'Create new view',
saveAsNew: 'Save as new view',
};
export const SAVED_VIEW_TOAST_POSITION = 'top-right' as const;

View File

@@ -0,0 +1,76 @@
import { useEffect, useMemo, useRef } from 'react';
import { useGetSavedView } from 'api/generated/services/saved-view';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { DataSource } from 'types/common/queryBuilder';
import { SAVED_VIEW_OPTIONS_DATA_SOURCE } from '../constants';
import { UseActiveSavedViewResult } from '../types';
import { hasUnsavedViewChanges } from '../utils/hasUnsavedViewChanges';
export function useActiveSavedView(
source: SavedviewtypesSourceDTO,
): UseActiveSavedViewResult {
const { viewKey } = useGetSavedViewParams();
const { stagedQuery, panelType } = useQueryBuilder();
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
});
const { data, isLoading, isError } = useGetSavedView(
{ id: viewKey },
{ query: { enabled: !!viewKey } },
);
const view = viewKey ? data?.data : undefined;
// The query builder stages the url query a render later. Until the ids
// match, keep the last answer for this view; another view starts clean.
const compositeQuery = useGetCompositeQueryParam();
const isStagedQueryCurrent =
!!stagedQuery && stagedQuery.id === compositeQuery?.id;
const lastResult = useRef<{ viewId?: string; hasUnsavedChanges: boolean }>({
hasUnsavedChanges: false,
});
const hasUnsavedChanges = useMemo((): boolean => {
if (!view) {
return false;
}
if (!isStagedQueryCurrent) {
return (
lastResult.current.viewId === view.id &&
lastResult.current.hasUnsavedChanges
);
}
return hasUnsavedViewChanges({
view,
stagedQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
options: optionsDataSource ? options : undefined,
});
}, [
view,
isStagedQueryCurrent,
stagedQuery,
panelType,
optionsDataSource,
options,
]);
useEffect(() => {
lastResult.current = { viewId: view?.id, hasUnsavedChanges };
}, [view?.id, hasUnsavedChanges]);
return {
view,
isLoading: !!viewKey && isLoading,
isError: !!viewKey && isError,
hasUnsavedChanges,
};
}

View File

@@ -0,0 +1,55 @@
import { useCallback } from 'react';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import {
PreservedViewsInLocalStorage,
PreservedViewType,
} from 'container/ExplorerOptions/types';
import { UseLastUsedViewResult } from '../types';
function readLastUsedViews(): PreservedViewsInLocalStorage {
try {
return JSON.parse(
getLocalStorageKey(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) || '{}',
) as PreservedViewsInLocalStorage;
} catch {
return {};
}
}
// Read at call time, not held in state: the hook is used from more than one
// place and a cached copy would bring back a view another caller just cleared.
export function useLastUsedView(
savedViewSource: SavedviewtypesSourceDTO,
): UseLastUsedViewResult {
// The bar's enum carries the same four values as the v2 source.
const source = savedViewSource as unknown as PreservedViewType;
const getLastUsedViewKey = useCallback(
(): string | undefined => readLastUsedViews()[source]?.key,
[source],
);
const setLastUsedView = useCallback(
(id: string, displayName: string): void => {
setLocalStorageKey(
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
JSON.stringify({
...readLastUsedViews(),
[source]: { key: id, value: displayName },
}),
);
},
[source],
);
const clearLastUsedView = useCallback((): void => {
const { [source]: _removed, ...rest } = readLastUsedViews();
setLocalStorageKey(LOCALSTORAGE.LAST_USED_SAVED_VIEWS, JSON.stringify(rest));
}, [source]);
return { getLastUsedViewKey, setLastUsedView, clearLastUsedView };
}

View File

@@ -0,0 +1,56 @@
import { useEffect, useRef, useState } from 'react';
import { useGetSavedView } from 'api/generated/services/saved-view';
import { QueryParams } from 'constants/query';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import { UseRestoreLastUsedViewArgs } from '../types';
import { useLastUsedView } from './useLastUsedView';
export function useRestoreLastUsedView({
source,
selectView,
}: UseRestoreLastUsedViewArgs): void {
const { viewKey } = useGetSavedViewParams();
const { getLastUsedViewKey, clearLastUsedView } = useLastUsedView(source);
// Decided on the first render, before the explorer writes its default query
// to the url; later an empty url means the view was cleared.
const [lastUsedViewKey] = useState(() => {
const params = getUnstableCurrentSearchParams();
const isBare =
!params.get(QueryParams.viewKey) && !params.get(QueryParams.compositeQuery);
return isBare ? getLastUsedViewKey() : undefined;
});
const hasRestored = useRef(false);
const shouldRestore = !!lastUsedViewKey && !viewKey && !hasRestored.current;
const { data, isError } = useGetSavedView(
{ id: lastUsedViewKey ?? '' },
{ query: { enabled: shouldRestore } },
);
const view = data?.data;
useEffect(() => {
if (!shouldRestore) {
return undefined;
}
if (isError) {
hasRestored.current = true;
clearLastUsedView();
return undefined;
}
if (!view) {
return undefined;
}
// Deferred like the bar's restore, so it lands after the explorer has
// written its default query to the url.
const timeoutId = setTimeout(() => {
hasRestored.current = true;
selectView(view);
}, 0);
return (): void => clearTimeout(timeoutId);
}, [shouldRestore, isError, view, clearLastUsedView, selectView]);
}

View File

@@ -0,0 +1,193 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { useLocation } from 'react-router-dom';
import { toast } from '@signozhq/ui/sonner';
import {
invalidateGetSavedView,
invalidateListSavedViews,
useCreateSavedView,
useUpdateSavedView,
} from 'api/generated/services/saved-view';
import {
RenderErrorResponseDTO,
SavedviewtypesSavedViewDTO,
SavedviewtypesSavedViewSpecDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { DataSource } from 'types/common/queryBuilder';
import { toAPIError } from 'utils/errorUtils';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import {
SAVED_VIEW_OPTIONS_DATA_SOURCE,
SAVED_VIEW_SCHEMA_VERSION,
SAVED_VIEW_TOAST_POSITION,
SAVED_VIEW_URL_PARAMS,
} from '../constants';
import { UseSavedViewActionsResult } from '../types';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
import { useLastUsedView } from './useLastUsedView';
// Columns and formatting need no handling here: with a viewKey in the url the
// preference provider reads them from the view, and without one it falls back
// to the user's own.
export function useSavedViewActions(
source: SavedviewtypesSourceDTO,
): UseSavedViewActionsResult {
const queryClient = useQueryClient();
const { pathname } = useLocation();
const { safeNavigate } = useSafeNavigate();
const { currentQuery, stagedQuery, panelType, redirectWithQueryBuilderData } =
useQueryBuilder();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { setLastUsedView, clearLastUsedView } = useLastUsedView(source);
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
});
const { mutateAsync: createSavedView, isLoading: isCreating } =
useCreateSavedView();
const { mutateAsync: updateSavedView, isLoading: isUpdating } =
useUpdateSavedView();
const invalidateViews = useCallback(
async (id?: string): Promise<void> => {
await Promise.all([
invalidateListSavedViews(queryClient),
id ? invalidateGetSavedView(queryClient, { id }) : undefined,
// The saved views bar still lists through v1.
queryClient.invalidateQueries([{ sourcepage: source }]),
]);
},
[queryClient, source],
);
const toCurrentSpec = useCallback(
(displayName: string, color?: string): SavedviewtypesSavedViewSpecDTO =>
toSavedViewSpec({
query: stagedQuery ?? currentQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
displayName,
options: optionsDataSource ? options : undefined,
color,
}),
[stagedQuery, currentQuery, panelType, optionsDataSource, options],
);
const selectView = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
handleExplorerTabChange(view.spec.panelType, {
query: getSavedViewQuery(view),
viewKey: view.id,
});
setLastUsedView(view.id, view.spec.displayName);
},
[handleExplorerTabChange, setLastUsedView],
);
// The preference provider re-reads a view's columns only when its list
// changes, so the list is refetched as the view is applied again.
const revertView = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
void invalidateListSavedViews(queryClient);
selectView(view);
},
[queryClient, selectView],
);
// The query builder redirects only ever add params, so the url is rebuilt
// here; the time range and everything else a view does not set stays.
const clearView = useCallback((): void => {
clearLastUsedView();
const params = getUnstableCurrentSearchParams();
SAVED_VIEW_URL_PARAMS.forEach((param) => params.delete(param));
safeNavigate(`${pathname}?${params.toString()}`);
}, [clearLastUsedView, pathname, safeNavigate]);
const createView = useCallback(
async (displayName: string): Promise<string | undefined> => {
try {
const { data } = await createSavedView({
data: {
generateName: true,
source,
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
spec: toCurrentSpec(displayName),
},
});
await invalidateViews();
redirectWithQueryBuilderData(stagedQuery ?? currentQuery, {
[QueryParams.viewKey]: data.id,
});
setLastUsedView(data.id, displayName);
return data.id;
} catch (error) {
toast.error(
toAPIError(
error as ErrorType<RenderErrorResponseDTO>,
'Could not save the view',
).getErrorMessage(),
{ position: SAVED_VIEW_TOAST_POSITION },
);
return undefined;
}
},
[
createSavedView,
source,
toCurrentSpec,
invalidateViews,
redirectWithQueryBuilderData,
stagedQuery,
currentQuery,
setLastUsedView,
],
);
const updateView = useCallback(
async (view: SavedviewtypesSavedViewDTO): Promise<boolean> => {
try {
await updateSavedView({
pathParams: { id: view.id },
data: {
source,
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
spec: toCurrentSpec(view.spec.displayName, view.spec.display?.color),
},
});
await invalidateViews(view.id);
return true;
} catch (error) {
toast.error(
toAPIError(
error as ErrorType<RenderErrorResponseDTO>,
'Could not update the view',
).getErrorMessage(),
{ position: SAVED_VIEW_TOAST_POSITION },
);
return false;
}
},
[updateSavedView, source, toCurrentSpec, invalidateViews],
);
return {
selectView,
revertView,
clearView,
createView,
updateView,
isSaving: isCreating || isUpdating,
};
}

View File

@@ -0,0 +1,83 @@
import { ReactElement } from 'react';
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { OptionsQuery } from 'container/OptionsMenu/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
export type SavedViewSourcePage = DataSource | 'meter';
export type SavedViewOptions = Pick<
OptionsQuery,
'selectColumns' | 'format' | 'maxLines' | 'fontSize'
>;
export interface ToSavedViewSpecArgs {
query: Query;
panelType: PANEL_TYPES;
displayName: string;
options?: SavedViewOptions;
color?: string;
}
export interface HasUnsavedViewChangesArgs {
view: SavedviewtypesSavedViewDTO;
stagedQuery: Query | null;
panelType: PANEL_TYPES;
options?: SavedViewOptions;
}
export interface UseLastUsedViewResult {
getLastUsedViewKey: () => string | undefined;
setLastUsedView: (id: string, displayName: string) => void;
clearLastUsedView: () => void;
}
export interface UseActiveSavedViewResult {
view: SavedviewtypesSavedViewDTO | undefined;
isLoading: boolean;
isError: boolean;
hasUnsavedChanges: boolean;
}
export interface UseSavedViewActionsResult {
selectView: (view: SavedviewtypesSavedViewDTO) => void;
revertView: (view: SavedviewtypesSavedViewDTO) => void;
clearView: () => void;
createView: (displayName: string) => Promise<string | undefined>;
updateView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
isSaving: boolean;
}
export interface UseRestoreLastUsedViewArgs {
source: SavedviewtypesSourceDTO;
selectView: UseSavedViewActionsResult['selectView'];
}
export type SaveViewModalMode = 'create' | 'saveAsNew';
export interface SaveViewModalProps {
mode: SaveViewModalMode;
isSaving: boolean;
onClose: () => void;
// Resolves true when saved; the modal stays open otherwise.
onSave: (displayName: string) => Promise<boolean>;
}
export interface SaveChangesMenuProps {
disabled: boolean;
onSaveAsNew: () => void;
onUpdate: () => void;
}
export interface SavedViewsIconButtonProps {
title: string;
icon: ReactElement;
testId: string;
color?: 'secondary' | 'warning';
disabled?: boolean;
onClick?: () => void;
}

View File

@@ -1,49 +0,0 @@
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryEnvelope } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
export type SavedViewSourcePage = DataSource | 'meter';
// Explorers and the preferences module are keyed by DataSource (the signal),
// the api keys views by source page. Same values today, so this is the one
// place they meet. AI observability views will come with their own source and
// DataSource cannot tell them apart from traces, so preferences should move to
// source page at that point and this map goes with it.
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
{
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
[DataSource.TRACES]: SavedviewtypesSourceDTO.traces,
[DataSource.METRICS]: SavedviewtypesSourceDTO.metrics,
meter: SavedviewtypesSourceDTO.meter,
};
export function toSavedViewSource(
sourcePage: SavedViewSourcePage,
): SavedviewtypesSourceDTO {
return SAVED_VIEW_SOURCE[sourcePage];
}
// Explorers only save builder queries; v2 carries no queryType, so it is fixed here.
export function getSavedViewQuery(view: SavedviewtypesSavedViewDTO): Query {
const { queries, panelType } = view.spec;
return mapQueryDataFromApi({
queries: queries as QueryEnvelope[],
panelType: panelType as unknown as PANEL_TYPES,
queryType: EQueryType.QUERY_BUILDER,
unit: undefined,
});
}
export function findSavedView(
views: SavedviewtypesSavedViewDTO[] | null | undefined,
id: string,
): SavedviewtypesSavedViewDTO | undefined {
return views?.find((view) => view.id === id);
}

View File

@@ -0,0 +1,20 @@
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { findSavedView } from '../findSavedView';
describe('findSavedView', () => {
const views = [{ id: 'a' }, { id: 'b' }] as SavedviewtypesSavedViewDTO[];
it('returns the view with the matching id', () => {
expect(findSavedView(views, 'b')?.id).toBe('b');
});
it('returns undefined when the id is not in the list', () => {
expect(findSavedView(views, 'c')).toBeUndefined();
});
it('returns undefined for a null or not yet loaded list', () => {
expect(findSavedView(null, 'a')).toBeUndefined();
expect(findSavedView(undefined, 'a')).toBeUndefined();
});
});

View File

@@ -8,7 +8,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { findSavedView, getSavedViewQuery, toSavedViewSource } from '../utils';
import { getSavedViewQuery } from '../getSavedViewQuery';
jest.mock('uuid', () => ({
v4: (): string => 'test-id',
@@ -91,36 +91,3 @@ describe('getSavedViewQuery', () => {
expect(query).not.toHaveProperty('panelType', PANEL_TYPES.TIME_SERIES);
});
});
describe('toSavedViewSource', () => {
it('maps every explorer source page to the v2 source', () => {
expect(toSavedViewSource(DataSource.LOGS)).toBe(SavedviewtypesSourceDTO.logs);
expect(toSavedViewSource(DataSource.TRACES)).toBe(
SavedviewtypesSourceDTO.traces,
);
expect(toSavedViewSource(DataSource.METRICS)).toBe(
SavedviewtypesSourceDTO.metrics,
);
expect(toSavedViewSource('meter')).toBe(SavedviewtypesSourceDTO.meter);
});
});
describe('findSavedView', () => {
const views = [
{ ...makeView(), id: 'a' },
{ ...makeView(), id: 'b' },
];
it('returns the view with the matching id', () => {
expect(findSavedView(views, 'b')?.id).toBe('b');
});
it('returns undefined when the id is not in the list', () => {
expect(findSavedView(views, 'c')).toBeUndefined();
});
it('returns undefined for a null or not yet loaded list', () => {
expect(findSavedView(null, 'a')).toBeUndefined();
expect(findSavedView(undefined, 'a')).toBeUndefined();
});
});

View File

@@ -0,0 +1,217 @@
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSavedViewSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
initialQueriesMap,
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
ensureLogsRequiredColumns,
} from 'container/OptionsMenu/constants';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { getSavedViewQuery } from '../getSavedViewQuery';
import { hasUnsavedViewChanges } from '../hasUnsavedViewChanges';
import { toSavedViewSpec } from '../toSavedViewSpec';
jest.mock('uuid', () => ({
v4: (): string => 'test-id',
}));
function query(dataSource: DataSource, expression = 'has_error = true'): Query {
const base = initialQueriesMap[dataSource];
return {
...base,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
filter: { expression },
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
},
],
},
} as Query;
}
// The server stores the queries through typed structs: nulls, empty strings,
// zeros and empty lists do not come back.
function asStoredByServer(value: unknown): unknown {
if (Array.isArray(value)) {
const items = value.map(asStoredByServer).filter((v) => v !== undefined);
return items.length > 0 ? items : undefined;
}
if (value !== null && typeof value === 'object') {
const entries = Object.entries(value)
.map(([key, v]) => [key, asStoredByServer(v)] as const)
.filter(([, v]) => v !== undefined);
return Object.fromEntries(entries);
}
return value === null || value === '' || value === 0 ? undefined : value;
}
function viewFrom(
spec: SavedviewtypesSavedViewSpecDTO,
): SavedviewtypesSavedViewDTO {
return {
id: 'view-1',
spec: {
...spec,
queries: asStoredByServer(
JSON.parse(JSON.stringify(spec.queries)),
) as SavedviewtypesSavedViewSpecDTO['queries'],
},
} as SavedviewtypesSavedViewDTO;
}
// What the query builder stages after the view is opened: the stored query,
// with its defaults filled in.
function stagedFrom(
view: SavedviewtypesSavedViewDTO,
dataSource: DataSource,
): Query {
const saved = getSavedViewQuery(view);
return {
...saved,
builder: {
...saved.builder,
queryData: saved.builder.queryData.map((queryData) => ({
...initialQueryBuilderFormValuesMap[dataSource],
...queryData,
})),
},
};
}
const columns = [
{ name: 'service.name', key: 'service.name' },
{ name: 'name', key: 'name' },
] as TelemetryFieldKey[];
const formatting = {
format: 'raw' as LogViewMode,
maxLines: 2,
fontSize: FontSize.SMALL,
};
describe('hasUnsavedViewChanges', () => {
const options = { selectColumns: columns, ...formatting };
const view = viewFrom(
toSavedViewSpec({
query: query(DataSource.TRACES),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
options,
}),
);
const staged = stagedFrom(view, DataSource.TRACES);
it('is dirty when the columns change', () => {
expect(
hasUnsavedViewChanges({
view,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
options: { ...options, selectColumns: [columns[0]] },
}),
).toBe(true);
});
it('reads a traces view without columns as the default columns', () => {
const bare = viewFrom(
toSavedViewSpec({
query: query(DataSource.TRACES),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
}),
);
expect(
hasUnsavedViewChanges({
view: bare,
stagedQuery: stagedFrom(bare, DataSource.TRACES),
panelType: PANEL_TYPES.LIST,
options: { ...options, selectColumns: defaultTraceSelectedColumns },
}),
).toBe(false);
});
describe('on logs', () => {
const logsView = viewFrom(
toSavedViewSpec({
query: query(DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
options: { selectColumns: columns, ...formatting },
}),
);
const logsStaged = stagedFrom(logsView, DataSource.LOGS);
// The explorer always adds the required logs columns.
const shownColumns = ensureLogsRequiredColumns(columns);
it('is clean with the required columns the explorer adds', () => {
expect(
hasUnsavedViewChanges({
view: logsView,
stagedQuery: logsStaged,
panelType: PANEL_TYPES.LIST,
options: { selectColumns: shownColumns, ...formatting },
}),
).toBe(false);
});
it('is dirty when the formatting changes', () => {
expect(
hasUnsavedViewChanges({
view: logsView,
stagedQuery: logsStaged,
panelType: PANEL_TYPES.LIST,
options: { selectColumns: shownColumns, ...formatting, maxLines: 5 },
}),
).toBe(true);
});
it('reads a view without formatting as the default formatting', () => {
const bare = viewFrom(
toSavedViewSpec({
query: query(DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
}),
);
expect(
hasUnsavedViewChanges({
view: bare,
stagedQuery: stagedFrom(bare, DataSource.LOGS),
panelType: PANEL_TYPES.LIST,
options: {
selectColumns: defaultLogsSelectedColumns,
format: 'table',
maxLines: 1,
fontSize: FontSize.SMALL,
},
}),
).toBe(false);
});
});
it('ignores formatting on traces', () => {
expect(
hasUnsavedViewChanges({
view,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
options: { ...options, ...formatting, maxLines: 5 },
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,17 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
import { toSavedViewSource } from '../toSavedViewSource';
describe('toSavedViewSource', () => {
it('maps every explorer source page to the v2 source', () => {
expect(toSavedViewSource(DataSource.LOGS)).toBe(SavedviewtypesSourceDTO.logs);
expect(toSavedViewSource(DataSource.TRACES)).toBe(
SavedviewtypesSourceDTO.traces,
);
expect(toSavedViewSource(DataSource.METRICS)).toBe(
SavedviewtypesSourceDTO.metrics,
);
expect(toSavedViewSource('meter')).toBe(SavedviewtypesSourceDTO.meter);
});
});

View File

@@ -0,0 +1,95 @@
import {
Querybuildertypesv5RequestTypeDTO,
SavedviewtypesPanelTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import { toSavedViewPanelType, toSavedViewSpec } from '../toSavedViewSpec';
jest.mock('uuid', () => ({
v4: (): string => 'test-id',
}));
function tracesQuery(): Query {
const base = initialQueriesMap.traces;
return {
...base,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
filter: { expression: 'has_error = true' },
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
},
],
},
} as Query;
}
const columns = [
{
name: 'service.name',
key: 'service.name',
signal: 'traces',
fieldContext: 'resource',
fieldDataType: 'string',
isIndexed: true,
},
{ key: 'name' },
] as unknown as TelemetryFieldKey[];
describe('toSavedViewPanelType', () => {
it('folds a dashboard only panel type to graph', () => {
expect(toSavedViewPanelType(PANEL_TYPES.PIE)).toBe(
SavedviewtypesPanelTypeDTO.graph,
);
});
});
describe('toSavedViewSpec', () => {
it('stores only the field identity of the columns, naming key only ones', () => {
const spec = toSavedViewSpec({
query: tracesQuery(),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
options: {
selectColumns: columns,
format: 'raw' as LogViewMode,
maxLines: 2,
fontSize: FontSize.SMALL,
},
});
expect(spec.selectedFields).toStrictEqual([
{
name: 'service.name',
signal: 'traces',
fieldContext: 'resource',
fieldDataType: 'string',
},
{ name: 'name' },
]);
expect(spec.display).toStrictEqual({
format: 'raw',
maxLines: 2,
fontSize: FontSize.SMALL,
});
});
it('keeps the colour in display', () => {
const spec = toSavedViewSpec({
query: tracesQuery(),
panelType: PANEL_TYPES.TIME_SERIES,
displayName: 'Errors',
color: 'red',
});
expect(spec.panelType).toBe(SavedviewtypesPanelTypeDTO.graph);
expect(spec.requestType).toBe(Querybuildertypesv5RequestTypeDTO.time_series);
expect(spec.display).toStrictEqual({ color: 'red' });
});
});

View File

@@ -0,0 +1,8 @@
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
export function findSavedView(
views: SavedviewtypesSavedViewDTO[] | null | undefined,
id: string,
): SavedviewtypesSavedViewDTO | undefined {
return views?.find((view) => view.id === id);
}

View File

@@ -0,0 +1,17 @@
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryEnvelope } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
// Explorers only save builder queries; v2 carries no queryType, so it is fixed here.
export function getSavedViewQuery(view: SavedviewtypesSavedViewDTO): Query {
const { queries, panelType } = view.spec;
return mapQueryDataFromApi({
queries: queries as QueryEnvelope[],
panelType: panelType as unknown as PANEL_TYPES,
queryType: EQueryType.QUERY_BUILDER,
unit: undefined,
});
}

View File

@@ -0,0 +1,49 @@
import { SavedviewtypesSavedViewSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
ensureLogsRequiredColumns,
} from 'container/OptionsMenu/constants';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import { Preferences } from 'providers/preferences/types';
import { DataSource } from 'types/common/queryBuilder';
function withColumnNames(
columns: TelemetryFieldKey[] | undefined,
): TelemetryFieldKey[] | null {
if (!columns) {
return null;
}
return columns.map((column) => ({
...column,
name: column.name ?? column.key,
}));
}
// The columns and formatting the explorer shows for a saved view, with the
// defaults filled in where the view has none.
export function getViewColumnsAndFormatting(
spec: SavedviewtypesSavedViewSpecDTO | undefined,
dataSource: DataSource,
): Preferences {
const selectedFields = spec?.selectedFields as TelemetryFieldKey[] | undefined;
if (dataSource === DataSource.LOGS) {
return {
columns: ensureLogsRequiredColumns(
withColumnNames(selectedFields) || defaultLogsSelectedColumns,
),
formatting: {
maxLines: spec?.display?.maxLines || 1,
format: (spec?.display?.format as LogViewMode) || 'table',
fontSize: (spec?.display?.fontSize as FontSize) || FontSize.SMALL,
version: 1,
},
};
}
if (dataSource === DataSource.TRACES) {
return { columns: selectedFields || defaultTraceSelectedColumns };
}
return { columns: [] };
}

View File

@@ -0,0 +1,81 @@
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import isEqual from 'lodash-es/isEqual';
import { DataSource } from 'types/common/queryBuilder';
import { HasUnsavedViewChangesArgs } from '../types';
import { toSavedViewPanelType } from './toSavedViewSpec';
import { getViewColumnsAndFormatting } from './getViewColumnsAndFormatting';
// The server drops empty values when it stores a query, while the query
// builder fills them with defaults, so both sides lose them before comparing.
function withoutEmptyValues(value: unknown): unknown {
if (Array.isArray(value)) {
const items = value
.map(withoutEmptyValues)
.filter((item) => item !== undefined);
return items.length > 0 ? items : undefined;
}
if (value !== null && typeof value === 'object') {
const entries = Object.entries(value)
.map(([key, item]) => [key, withoutEmptyValues(item)] as const)
.filter(([, item]) => item !== undefined);
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
}
if (value === null || value === '' || value === 0) {
return undefined;
}
return value;
}
// Compared as stored (v5 envelopes): mapped back to the builder's shape, each
// side gets different defaults.
export function hasUnsavedViewChanges({
view,
stagedQuery,
panelType,
options,
}: HasUnsavedViewChangesArgs): boolean {
if (!stagedQuery) {
return false;
}
if (view.spec.panelType !== toSavedViewPanelType(panelType)) {
return true;
}
const { queries: stagedQueries } = mapCompositeQueryFromQuery(
stagedQuery,
panelType,
);
if (
!isEqual(
withoutEmptyValues(view.spec.queries),
withoutEmptyValues(stagedQueries),
)
) {
return true;
}
if (!options) {
return false;
}
const dataSource = stagedQuery.builder.queryData[0]?.dataSource;
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
dataSource,
);
const toNames = (fields: { name?: string; key?: string }[]): unknown[] =>
fields.map((field) => field.name ?? field.key);
if (!isEqual(toNames(columns), toNames(options.selectColumns ?? []))) {
return true;
}
if (dataSource !== DataSource.LOGS || !formatting) {
return false;
}
return (
formatting.format !== options.format ||
formatting.maxLines !== options.maxLines ||
formatting.fontSize !== options.fontSize
);
}

View File

@@ -0,0 +1,23 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewSourcePage } from '../types';
// Explorers and the preferences module are keyed by DataSource (the signal),
// the api keys views by source page. Same values today, so this is the one
// place they meet. AI observability views will come with their own source and
// DataSource cannot tell them apart from traces, so preferences should move to
// source page at that point and this map goes with it.
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
{
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
[DataSource.TRACES]: SavedviewtypesSourceDTO.traces,
[DataSource.METRICS]: SavedviewtypesSourceDTO.metrics,
meter: SavedviewtypesSourceDTO.meter,
};
export function toSavedViewSource(
sourcePage: SavedViewSourcePage,
): SavedviewtypesSourceDTO {
return SAVED_VIEW_SOURCE[sourcePage];
}

View File

@@ -0,0 +1,80 @@
import {
Querybuildertypesv5QueryEnvelopeDTO,
SavedviewtypesDisplayDTO,
SavedviewtypesPanelTypeDTO,
SavedviewtypesSavedViewSpecDTO,
TelemetrytypesFieldContextDTO,
TelemetrytypesFieldDataTypeDTO,
TelemetrytypesSignalDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import { panelTypeToRequestType } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { ToSavedViewSpecArgs } from '../types';
const SAVED_VIEW_PANEL_TYPES = new Set<string>(
Object.values(SavedviewtypesPanelTypeDTO),
);
// Explorers only produce list / trace / graph / table; anything else a caller
// hands over (a dashboard panel type) is stored as a time series view.
export function toSavedViewPanelType(
panelType: PANEL_TYPES,
): SavedviewtypesPanelTypeDTO {
return SAVED_VIEW_PANEL_TYPES.has(panelType)
? (panelType as unknown as SavedviewtypesPanelTypeDTO)
: SavedviewtypesPanelTypeDTO.graph;
}
// The api stores the field identity only; `key` and the column flags are ui state.
function toFieldKey(
column: TelemetryFieldKey,
): TelemetrytypesTelemetryFieldKeyDTO {
return {
name: column.name ?? column.key ?? '',
...(column.signal && {
signal: column.signal as unknown as TelemetrytypesSignalDTO,
}),
...(column.fieldContext && {
fieldContext:
column.fieldContext as unknown as TelemetrytypesFieldContextDTO,
}),
...(column.fieldDataType && {
fieldDataType:
column.fieldDataType as unknown as TelemetrytypesFieldDataTypeDTO,
}),
...(column.description && { description: column.description }),
...(column.unit && { unit: column.unit }),
};
}
export function toSavedViewSpec({
query,
panelType,
displayName,
options,
color,
}: ToSavedViewSpecArgs): SavedviewtypesSavedViewSpecDTO {
const { queries } = mapCompositeQueryFromQuery(query, panelType);
const display: SavedviewtypesDisplayDTO = {
...(color && { color }),
...(options?.format && { format: options.format }),
...(options?.maxLines && { maxLines: options.maxLines }),
...(options?.fontSize && { fontSize: options.fontSize }),
};
return {
displayName,
panelType: toSavedViewPanelType(panelType),
requestType: panelTypeToRequestType(panelType),
queries: (queries ?? []) as unknown as Querybuildertypesv5QueryEnvelopeDTO[],
...(options?.selectColumns?.length && {
selectedFields: options.selectColumns.map(toFieldKey),
}),
...(Object.keys(display).length > 0 && { display }),
};
}

View File

@@ -73,32 +73,20 @@ export const useHandleExplorerTabChange = (): {
}
const query = currentQueryData?.query || getUpdateQuery(newPanelType);
const nextViewKey = currentQueryData?.viewKey || viewKey;
const nextViewName = currentQueryData?.viewName || viewName;
if (redirectToUrl) {
redirectWithQueryBuilderData(
query,
{
[QueryParams.panelTypes]: newPanelType,
[QueryParams.viewName]: currentQueryData?.viewName || viewName,
[QueryParams.viewKey]: currentQueryData?.viewKey || viewKey,
},
redirectToUrl,
undefined,
newTab,
);
} else {
redirectWithQueryBuilderData(
query,
{
[QueryParams.panelTypes]: newPanelType,
[QueryParams.viewName]: currentQueryData?.viewName || viewName,
[QueryParams.viewKey]: currentQueryData?.viewKey || viewKey,
},
undefined,
undefined,
newTab,
);
}
redirectWithQueryBuilderData(
query,
{
[QueryParams.panelTypes]: newPanelType,
...(nextViewKey ? { [QueryParams.viewKey]: nextViewKey } : {}),
...(nextViewName ? { [QueryParams.viewName]: nextViewName } : {}),
},
redirectToUrl,
undefined,
newTab,
);
},
[panelType, getUpdateQuery, redirectWithQueryBuilderData, viewName, viewKey],
);

Some files were not shown because too many files have changed in this diff Show More