Compare commits

...

14 Commits

Author SHA1 Message Date
Ashwin Bhatkal
02ad3d0a05 fix(e2e): stub the v2 channel list so the empty-channel scenarios still see no channels 2026-10-16 20:30:43 +05:30
Ashwin Bhatkal
9741734aa9 refactor(alerts): read the channel pickers from the v2 list API 2026-10-16 20:30:43 +05:30
Ashwin Bhatkal
23a1dca192 refactor(notification-channels): share the listed-channel type and read table cells off the typed row 2026-10-16 20:30:41 +05:30
Ashwin Bhatkal
e9284bac49 fix(notification-channels): move the channels list onto the @signozhq/ui 0.2.0 component API 2026-10-16 20:30:41 +05:30
Ashwin Bhatkal
410622bf9f fix(notification-channels): show an error state when the channel JSON fails to load 2026-10-16 20:30:41 +05:30
Ashwin Bhatkal
337fefa9bc refactor(notification-channels): show the channel JSON with the shared editor theme 2026-10-16 20:30:41 +05:30
Ashwin Bhatkal
782f17864e refactor(notification-channels): delete a channel through the shared destructive confirmation 2026-10-16 20:30:41 +05:30
Ashwin Bhatkal
6bcb0c8ba3 feat(notification-channels): rebuild the channels list on the v2 API and the shared table 2026-10-16 20:30:41 +05:30
Nityananda Gohain
531504c523 fix(tracedetail): keep litellm tool calls and AI SDK JSON answers, mark redacted media (#13166)
<!--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
- AI SDK: a JSON answer such as `{"answer": 42}` was dropped, and a JSON
list was read as nameless tool calls. The output is now tool calls only
when every item has `toolName`;
  anything else is kept as text.
- litellm: parallel calls (`gen_ai.completion.N` numbers tool calls, not
choices), a call next to text, and the history's `tool_calls` /
`tool_call_id` kept beside `parts` now become tool call and tool
response parts.
- Redaction placeholders (OpenInference's `__REDACTED__`, OpenRouter's
`[OR_REDACTED]`) no longer become `uri` parts: media without a URL
scheme is a generic part, with a warning.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Part of
https://github.com/SigNoz/nerve-pod/issues/266#issuecomment-6198620761

<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information
- Checked against real spans from litellm 1.105.1, OpenInference 0.1.49
and AI SDK 5.0.277 run against a local stub server. Replaying the
static-telemetry-generator captures changes only the four affected
spans.
- OpenInference redacts `input.value` too, so a redacted image cannot be
recovered from the raw request.
2026-10-16 14:23:02 +00:00
Nikhil Soni
b770c249a0 feat(querier): read traces json numbers as toFloat64OrNull and index that (#13101)
#### Description

- The querier reads a traces JSON number as
`toFloat64OrNull(p::String)`: every numeric type and a numeric string
read as Float64. Replaces the `dynamicType` gate around
`accurateCastOrNull`.
- The `toFloat64` collision cast leaves a read already starting
`toFloat64OrNull(` bare.

Why avoiding the `toFloat64` wrapper on json number queries?
The wrapper exists for the map (`Bad get: has UInt64, requested
Float64`) and hid the read from its skip index.



#### Additional Information

- A number-typed key now matches a value stored as a numeric string
(`"404"`), and an integer above 2^53 reads rounded instead of NULL.

#### Performance Impact
Almost none

```
case: num = all values already numbers
case: mixed = values are numbers and stringified mixed
rows: 20M
┌──────────────────────────────────────────────────────┬───────┬─────────────┬─────────┐
│                         Read                         │  num  │    mixed    │   ≈     │
│                                                      │       │             │ ns/row  │
├──────────────────────────────────────────────────────┼───────┼─────────────┼─────────┤
│ new toFloat64OrNull(p::String)                       │ 0.56s │ 0.56s       │ 28      │
├──────────────────────────────────────────────────────┼───────┼─────────────┼─────────┤
│ old dynamicType gate (the QB wrapped it in           │ 0.57s │ 0.77s       │ 29 / 38 │
│ toFloat64)                                           │       │             │         │
└──────────────────────────────────────────────────────┴───────┴─────────────┴─────────┘
```
2026-10-16 14:04:08 +00:00
Nikhil Soni
6e6d7ebe8e feat(promote): index materialized paths on their json sub-columns (#13054)
Some checks failed
build-staging / prepare (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
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
## Description

- Adds `POST /api/v1/promoted_paths/materialized`: indexes the JSON
sub-columns of the signal's materialized attributes whose materialized
column has a skip index, and returns the indexed paths and the skipped
ones (already indexed).
- Each JSON index mirrors the type and granularity of the materialized
column's index, typed by the column (`String` or `Float64`), and is
built on new parts only. Reruns are no-ops.
- Only signals with a JSON attribute target are accepted (traces today),
so `logs` works once its attributes get a JSON target. Authz checks the
update scope of that signal.

## Additional Information

- The traces querier reads numbers through a `dynamicType`-gated cast,
so `Float64` sub-column indexes are not used until the querier supports
them.
- A materialized key that can't be indexed (unsupported index type,
bool, cardinal path) is logged and left out.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:33:51 +00:00
Abhi kumar
aef60bad5d feat(charts): mark partial time buckets with a dotted ring (#13165)
#### Description

- The last (and first) bucket of a time series is often still filling.
The API already flags these with `partial: true`, but charts drew them
as normal points, so panels showed a fake drop at the right edge
(customer report: 1,575 → 281).
- Time series and Area panels (V1 and V2 dashboards) now draw a dot with
a dotted ring on partial points; Bar panels get a dotted outline around
the partial bar. Lines and bars are otherwise unchanged.
- The tooltip header shows "(partial)" when hovering a partial bucket.
- The flag is carried through the existing data paths
(`flattenTimeSeries` for V2; converter → `convertNewDataToOld` →
`QueryData.partialTimestamps` for V1) into the shared uPlotV2 series
config, where a draw hook renders the markers. Charts that don't pass
partial timestamps are unchanged.

#### Screenshots

<img width="790" height="502" alt="image"
src="https://github.com/user-attachments/assets/15779208-2143-44c4-993e-49d4cd5a3a79"
/>
<img width="1034" height="734" alt="image"
src="https://github.com/user-attachments/assets/9e968a4b-b554-4fac-a57f-46afa94f7e4f"
/>


#### Additional Information

- Metrics queries round the end time down to the minute, so their last
bucket is rarely partial. Traces/logs panels show the marker most
readily.
- The bar outline re-runs that series' bar path builder for the one
partial bar to get its rect (the width calc scans the series once per
partial bar per redraw).

Closes https://github.com/SigNoz/pulse-pod/issues/592
2026-10-16 11:53:14 +00:00
Jugal Kishore
7d77577d75 fix(onboarding): link docs by final URL and dedupe swift data source (#13161)
#### Description

- Point 22 onboarding doc links to their final URLs instead of paths
that only resolve through signoz.io redirects.
- Rename the iOS Swift card's `dataSource` to `ios-swift`; it shared
`swift` with the Swift APM card, so `?getStartedSource=swift` and card
selection could not tell them apart.
- Recolor or trim 13 logos that were invisible or unreadable on the dark
or light card (Render, CloudNativePG, LiveKit, Apache Druid, HCP Vault,
Heroku, Podman, GitHub, Celery, OpenCode, Vector, Baseten, Honeycomb).

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

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

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

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

---------

Co-authored-by: Manika Malhotra <manika@signoz.io>
2026-10-16 10:09:41 +00:00
Vikrant Gupta
f1bf33630f feat(authz): enable FGA for infra monitoring routes (#13143)
#### Description

- Infra monitoring routes were still on the coarse `ViewAccess` gate, so
a custom role scoped to a workspace key on metrics could read every
host, pod and workload through them while being denied on `query_range`.
- All 12 routes now declare a telemetry resource def: lists derive the
resource from the caller's `filter.expression` exactly as `query_range`
does, checks resolves to the metrics wildcard. Managed roles are
unaffected; key-scoped custom roles get 403 unless the filter pins a
granted key.
- Adds four generic extractors in `querybuilder` (filter-expression
body, wildcard, raw stream, builder query) that the other telemetry
surfaces will reuse in follow-up PRs.

#### Issues closed by this PR

Part of SigNoz/keystone-pod#141
2026-10-16 09:33:07 +00:00
128 changed files with 3987 additions and 363 deletions

View File

@@ -8046,6 +8046,31 @@ components:
- metric
- value
type: object
PromotetypesIndexMaterializedPathsResult:
properties:
indexed:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
skipped:
items:
$ref: '#/components/schemas/PromotetypesPromotePath'
nullable: true
type: array
required:
- indexed
- skipped
type: object
PromotetypesPostableIndexMaterializedPaths:
properties:
dryRun:
type: boolean
signal:
type: string
required:
- signal
type: object
PromotetypesPromotePath:
properties:
context:
@@ -14072,6 +14097,67 @@ paths:
tags:
- promote
x-signoz-stability: alpha
/api/v1/promoted_paths/materialized:
post:
deprecated: false
description: This endpoint indexes the JSON sub-columns of materialized paths
whose columns are indexed and returns the indexed and skipped paths.
operationId: IndexMaterializedPaths
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PromotetypesPostableIndexMaterializedPaths'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/PromotetypesIndexMaterializedPathsResult'
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:
- logs-field:update
- traces-field:update
- tokenizer:
- logs-field:update
- traces-field:update
summary: Index materialized paths
tags:
- promote
x-signoz-stability: alpha
/api/v1/roles:
get:
deprecated: false
@@ -18914,9 +19000,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Run Infra Monitoring Setup Checks
tags:
- inframonitoring
@@ -18988,9 +19074,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: List Clusters for Infra Monitoring
tags:
- inframonitoring
@@ -19072,9 +19158,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: List DaemonSets for Infra Monitoring
tags:
- inframonitoring
@@ -19150,9 +19236,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: List Deployments for Infra Monitoring
tags:
- inframonitoring
@@ -19218,9 +19304,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: List Hosts for Infra Monitoring
tags:
- inframonitoring
@@ -19300,9 +19386,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: List Jobs for Infra Monitoring
tags:
- inframonitoring
@@ -19381,9 +19467,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: List Kubernetes Containers for Infra Monitoring
tags:
- inframonitoring
@@ -19452,9 +19538,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: List Namespaces for Infra Monitoring
tags:
- inframonitoring
@@ -19527,9 +19613,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: List Nodes for Infra Monitoring
tags:
- inframonitoring
@@ -19603,9 +19689,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: List Pods for Infra Monitoring
tags:
- inframonitoring
@@ -19674,9 +19760,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: List Volumes for Infra Monitoring
tags:
- inframonitoring
@@ -19752,9 +19838,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: List StatefulSets for Infra Monitoring
tags:
- inframonitoring

View File

@@ -18,8 +18,10 @@ import type {
} from 'react-query';
import type {
IndexMaterializedPaths200,
ListPromotedPaths200,
ListPromotedPathsParams,
PromotetypesPostableIndexMaterializedPathsDTO,
PromotetypesPromotePathDTO,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
@@ -230,3 +232,87 @@ export const usePromotePaths = <
> => {
return useMutation(getPromotePathsMutationOptions(options));
};
/**
* This endpoint indexes the JSON sub-columns of materialized paths whose columns are indexed and returns the indexed and skipped paths.
* @summary Index materialized paths
*/
export const indexMaterializedPaths = (
promotetypesPostableIndexMaterializedPathsDTO?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<IndexMaterializedPaths200>({
url: `/api/v1/promoted_paths/materialized`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: promotetypesPostableIndexMaterializedPathsDTO,
signal,
});
};
export const getIndexMaterializedPathsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof indexMaterializedPaths>>,
TError,
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof indexMaterializedPaths>>,
TError,
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
TContext
> => {
const mutationKey = ['indexMaterializedPaths'];
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 indexMaterializedPaths>>,
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> }
> = (props) => {
const { data } = props ?? {};
return indexMaterializedPaths(data);
};
return { mutationFn, ...mutationOptions };
};
export type IndexMaterializedPathsMutationResult = NonNullable<
Awaited<ReturnType<typeof indexMaterializedPaths>>
>;
export type IndexMaterializedPathsMutationBody =
| BodyType<PromotetypesPostableIndexMaterializedPathsDTO>
| undefined;
export type IndexMaterializedPathsMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Index materialized paths
*/
export const useIndexMaterializedPaths = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof indexMaterializedPaths>>,
TError,
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof indexMaterializedPaths>>,
TError,
{ data?: BodyType<PromotetypesPostableIndexMaterializedPathsDTO> },
TContext
> => {
return useMutation(getIndexMaterializedPathsMutationOptions(options));
};

View File

@@ -9878,6 +9878,28 @@ export interface PromotetypesPromotePathDTO {
signal: string;
}
export interface PromotetypesIndexMaterializedPathsResultDTO {
/**
* @type array,null
*/
indexed: PromotetypesPromotePathDTO[] | null;
/**
* @type array,null
*/
skipped: PromotetypesPromotePathDTO[] | null;
}
export interface PromotetypesPostableIndexMaterializedPathsDTO {
/**
* @type boolean
*/
dryRun?: boolean;
/**
* @type string
*/
signal: string;
}
export interface Querybuildertypesv5AggregationMetaDTO {
/**
* @type array
@@ -13250,6 +13272,14 @@ export type ListPromotedPaths200 = {
status: string;
};
export type IndexMaterializedPaths200 = {
data: PromotetypesIndexMaterializedPathsResultDTO;
/**
* @type string
*/
status: string;
};
export type ListRoles200 = {
/**
* @type array

View File

@@ -61,7 +61,11 @@ describe('convertV5ResponseToLegacy', () => {
],
values: [
{ timestamp: 1000, value: 10 } as unknown as TimeSeriesValue,
{ timestamp: 2000, value: 12 } as unknown as TimeSeriesValue,
{
timestamp: 2000,
value: 12,
partial: true,
} as unknown as TimeSeriesValue,
],
} as unknown as TimeSeries,
],
@@ -104,7 +108,7 @@ describe('convertV5ResponseToLegacy', () => {
labels: { 'service.name': 'adservice' },
values: [
{ timestamp: 1000, value: '10' },
{ timestamp: 2000, value: '12' },
{ timestamp: 2000, value: '12', partial: true },
],
metaData: expect.objectContaining({
alias: '__result_0',

View File

@@ -125,6 +125,7 @@ function convertTimeSeriesData(
values: series.values.map((value: any) => ({
timestamp: value.timestamp,
value: String(value.value),
...(value.partial ? { partial: true } : {}),
})),
metaData: {
alias,

View File

@@ -1,3 +1,3 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="#29F1FB" d="M8.932 20.806c-.369 0-.738.007-1.109 0-.35-.007-.587-.206-.623-.5a.587.587 0 0 1 .53-.636c.79-.062 1.582-.063 2.372-.003a.548.548 0 0 1 .522.602c-.024.326-.253.526-.616.54zM1.792 8.345c-.392 0-.782.008-1.173.002-.327-.006-.577-.22-.614-.512-.037-.293.146-.544.499-.615.192-.032.388-.045.583-.039a81.515 81.515 0 0 1 1.597 0c.163 0 .325.019.483.056.288.073.445.318.411.617-.034.298-.214.477-.515.487-.424.014-.848.004-1.272.004zm7.588 8.417H4.292a2.464 2.464 0 0 1-.326-.007c-.294-.04-.48-.209-.508-.506-.029-.298.11-.501.391-.606.179-.065.365-.051.549-.051 3.347 0 6.695.005 10.042-.006 1.174-.004 2.187-.439 2.993-1.3.69-.738 1.053-1.63 1.16-2.635.085-.788-.027-1.513-.516-2.156-.544-.718-1.28-1.078-2.163-1.082-3.163-.013-6.328-.005-9.487-.01-.336 0-.673-.027-1.007-.058-.29-.027-.45-.201-.469-.492-.021-.317.141-.545.429-.6a1.55 1.55 0 0 1 .29-.015h10.177c1.71.004 3.187 1.038 3.726 2.654.383 1.147.246 2.304-.182 3.416-.824 2.135-2.762 3.448-5.055 3.454-1.652.005-3.304 0-4.956 0zm2.906-13.568c1.533 0 3.066-.008 4.598 0 2.935.018 5.629 1.892 6.653 4.626.442 1.181.538 2.403.412 3.657-.185 1.842-.735 3.552-1.776 5.084-1.608 2.365-3.873 3.68-6.679 4.118-.95.148-1.905.13-2.86.13-.397 0-.61-.181-.633-.51-.025-.351.196-.621.587-.645.434-.026.87-.004 1.305-.016 2.641-.072 4.928-.982 6.74-2.935 1.269-1.37 1.912-3.039 2.13-4.878.151-1.275.135-2.544-.37-3.752-.773-1.85-2.159-2.983-4.068-3.509-.74-.204-1.5-.243-2.26-.247-2.837-.017-5.675-.007-8.511-.007-.12 0-.24.004-.359-.006a.57.57 0 0 1-.517-.536.557.557 0 0 1 .456-.557c.13-.018.261-.024.392-.019h4.762Z"/>
<path fill="#0E9AA7" d="M8.932 20.806c-.369 0-.738.007-1.109 0-.35-.007-.587-.206-.623-.5a.587.587 0 0 1 .53-.636c.79-.062 1.582-.063 2.372-.003a.548.548 0 0 1 .522.602c-.024.326-.253.526-.616.54zM1.792 8.345c-.392 0-.782.008-1.173.002-.327-.006-.577-.22-.614-.512-.037-.293.146-.544.499-.615.192-.032.388-.045.583-.039a81.515 81.515 0 0 1 1.597 0c.163 0 .325.019.483.056.288.073.445.318.411.617-.034.298-.214.477-.515.487-.424.014-.848.004-1.272.004zm7.588 8.417H4.292a2.464 2.464 0 0 1-.326-.007c-.294-.04-.48-.209-.508-.506-.029-.298.11-.501.391-.606.179-.065.365-.051.549-.051 3.347 0 6.695.005 10.042-.006 1.174-.004 2.187-.439 2.993-1.3.69-.738 1.053-1.63 1.16-2.635.085-.788-.027-1.513-.516-2.156-.544-.718-1.28-1.078-2.163-1.082-3.163-.013-6.328-.005-9.487-.01-.336 0-.673-.027-1.007-.058-.29-.027-.45-.201-.469-.492-.021-.317.141-.545.429-.6a1.55 1.55 0 0 1 .29-.015h10.177c1.71.004 3.187 1.038 3.726 2.654.383 1.147.246 2.304-.182 3.416-.824 2.135-2.762 3.448-5.055 3.454-1.652.005-3.304 0-4.956 0zm2.906-13.568c1.533 0 3.066-.008 4.598 0 2.935.018 5.629 1.892 6.653 4.626.442 1.181.538 2.403.412 3.657-.185 1.842-.735 3.552-1.776 5.084-1.608 2.365-3.873 3.68-6.679 4.118-.95.148-1.905.13-2.86.13-.397 0-.61-.181-.633-.51-.025-.351.196-.621.587-.645.434-.026.87-.004 1.305-.016 2.641-.072 4.928-.982 6.74-2.935 1.269-1.37 1.912-3.039 2.13-4.878.151-1.275.135-2.544-.37-3.752-.773-1.85-2.159-2.983-4.068-3.509-.74-.204-1.5-.243-2.26-.247-2.837-.017-5.675-.007-8.511-.007-.12 0-.24.004-.359-.006a.57.57 0 0 1-.517-.536.557.557 0 0 1 .456-.557c.13-.018.261-.024.392-.019h4.762Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,3 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#9CA3AF" height="34" viewBox="0 0 131 34">
<path fill="#9CA3AF" d="M.36 8.6h16.7v5.6H6.04c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h11.02v5.6h-5.33c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h4.98c.2 0 .35-.15.35-.35V25.4h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34v-5.6h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34V3.35c0-.2-.16-.35-.35-.35H.36c-.2 0-.36.16-.36.35v4.9c0 .2.16.35.36.35ZM44.41 14.7c-.5-.5-1.1-.9-1.76-1.18a5.62 5.62 0 0 0-4.6.17c-.73.37-1.32.91-1.75 1.62h-.17V8.59H34.1v16.83h2.04v-1.81h.17c.21.36.47.67.77.94.31.25.65.48 1.01.67.37.18.77.31 1.18.39a6.2 6.2 0 0 0 3.39-.24 5.36 5.36 0 0 0 3.02-3.1c.29-.75.44-1.62.44-2.6v-.47c0-.96-.16-1.83-.47-2.58-.3-.75-.7-1.4-1.23-1.9v-.01Zm-5.87.66a3.9 3.9 0 0 1 4.34.84c.36.35.64.8.83 1.3.2.5.3 1.07.3 1.7v.47c0 .64-.1 1.23-.3 1.74a3.75 3.75 0 0 1-2.06 2.15 4.27 4.27 0 0 1-3.12-.03 3.86 3.86 0 0 1-2.09-2.2c-.2-.52-.3-1.11-.3-1.75v-.29c0-.62.1-1.2.3-1.7v-.01c.21-.53.5-.99.84-1.36.36-.37.78-.66 1.26-.86ZM97.04 8.59H95v4.86h-2.94v1.86H95v8.17c0 .56.17 1.03.53 1.4.37.35.84.54 1.4.54h4.18v-1.87h-3.5c-.2 0-.33-.05-.43-.15-.1-.1-.14-.27-.14-.49v-7.6h4.65v-1.86h-4.65V8.59ZM114.61 15a5.48 5.48 0 0 0-1.8-1.33 5.6 5.6 0 0 0-2.57-.56 6.17 6.17 0 0 0-4.26 1.7 5.6 5.6 0 0 0-1.72 4.2v.57c0 .9.15 1.75.44 2.5a5.58 5.58 0 0 0 5.5 3.67c1.55 0 2.8-.35 3.72-1.04a5.35 5.35 0 0 0 1.91-2.73l.03-.07-1.94-.52-.02.07c-.11.33-.27.64-.46.94-.17.27-.4.52-.7.74-.28.22-.63.39-1.04.51-.41.13-.9.19-1.46.19a3.8 3.8 0 0 1-2.84-1.05 4.07 4.07 0 0 1-1.1-2.7h9.68v-1.6c0-.54-.11-1.12-.34-1.75a5.04 5.04 0 0 0-1.03-1.74Zm-8.25 3.21a3.8 3.8 0 0 1 1.22-2.25 4.19 4.19 0 0 1 3.99-.7c.44.16.83.38 1.17.66.34.27.62.62.82 1.02.21.38.34.8.38 1.27h-7.58ZM129.09 14.42a4.47 4.47 0 0 0-3.37-1.3c-.93 0-1.73.2-2.4.59-.64.39-1.15.97-1.52 1.74h-.17v-2h-2.04v11.97h2.04v-6.23c0-1.26.32-2.28.95-3.02a3.31 3.31 0 0 1 2.65-1.14c.94 0 1.7.3 2.24.9.56.6.83 1.52.83 2.74v6.75h2.04v-7.13c0-1.71-.42-3.02-1.25-3.87ZM88.1 15a5.48 5.48 0 0 0-1.78-1.33 5.6 5.6 0 0 0-2.58-.56 6.17 6.17 0 0 0-4.27 1.7 5.59 5.59 0 0 0-1.71 4.2v.56c0 .92.14 1.76.44 2.51a5.6 5.6 0 0 0 5.5 3.67c1.55 0 2.8-.35 3.72-1.04a5.36 5.36 0 0 0 1.91-2.73l.03-.07-1.94-.52-.03.07c-.1.32-.26.64-.45.94-.17.27-.4.52-.7.74-.29.21-.64.39-1.05.51-.4.12-.9.19-1.45.19a3.8 3.8 0 0 1-2.85-1.05 4.07 4.07 0 0 1-1.09-2.7h9.68v-1.61c0-.53-.12-1.12-.34-1.74A5.03 5.03 0 0 0 88.1 15Zm-8.24 3.21a3.83 3.83 0 0 1 1.22-2.25 4.2 4.2 0 0 1 3.99-.7c.44.16.83.38 1.16.66.35.27.62.62.83 1.02.2.38.33.8.37 1.27h-7.57ZM73.65 19.42a6.11 6.11 0 0 0-3.23-1.02 6.63 6.63 0 0 1-2.68-.58c-.47-.3-.7-.7-.7-1.25 0-.27.08-.5.21-.7.14-.2.33-.38.56-.52a4.05 4.05 0 0 1 1.78-.42c.85 0 1.54.21 2.06.63.53.41.83 1 .91 1.73l.01.1 1.95-.47-.01-.07c-.07-.45-.22-.91-.45-1.36a3.46 3.46 0 0 0-.94-1.2 4.6 4.6 0 0 0-1.52-.84 6.05 6.05 0 0 0-2.1-.34c-.58 0-1.13.07-1.65.22-.52.14-1 .36-1.43.65-.41.3-.74.66-.99 1.1a2.9 2.9 0 0 0-.37 1.49v.14c0 1.06.38 1.87 1.14 2.42a6.2 6.2 0 0 0 3.24.97c1.18.08 2.04.26 2.56.54.5.28.75.72.75 1.36 0 .6-.25 1.06-.78 1.4-.52.32-1.22.48-2.1.48a3.68 3.68 0 0 1-2.46-.79 3.13 3.13 0 0 1-1.04-2.14v-.09l-1.93.46h-.02v.07a4.4 4.4 0 0 0 3.1 4c.7.24 1.52.36 2.46.36.7 0 1.35-.09 1.93-.27.6-.16 1.12-.4 1.53-.72A3.38 3.38 0 0 0 74.8 22v-.14c0-1.07-.39-1.9-1.14-2.44ZM60.25 23.4c-.1-.1-.14-.27-.14-.49v-9.46h-2.05v1.85h-.16a3.78 3.78 0 0 0-1.61-1.63 4.62 4.62 0 0 0-2.26-.56c-.77 0-1.5.14-2.19.41a5.27 5.27 0 0 0-3.02 3.12c-.29.75-.44 1.63-.44 2.6v.38c0 .99.15 1.87.44 2.63.3.75.7 1.4 1.2 1.93a5.48 5.48 0 0 0 4.05 1.57c.8 0 1.51-.2 2.2-.58.69-.38 1.24-.97 1.63-1.75h.16v.06c0 .56.18 1.03.53 1.4.37.35.85.54 1.41.54h1.36v-1.87h-.68c-.2 0-.34-.05-.43-.15Zm-4.46.13c-.46.2-.97.3-1.52.3a3.68 3.68 0 0 1-2.75-1.09 4.42 4.42 0 0 1-1.05-3.12v-.38c0-.62.1-1.2.29-1.71a3.65 3.65 0 0 1 5-2.17c.47.2.88.49 1.21.86.35.37.62.83.8 1.36.2.5.3 1.08.3 1.7v.3c0 .63-.1 1.23-.3 1.76-.18.5-.45.96-.78 1.33-.33.37-.73.66-1.2.86Z"/>
</svg>
<svg role="img" viewBox="-2.625 3 28 28" xmlns="http://www.w3.org/2000/svg"><path fill="#7A8291" d="M.36 8.6h16.7v5.6H6.04c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h11.02v5.6h-5.33c-.2 0-.35.16-.35.35v4.9c0 .2.16.35.35.35h4.98c.2 0 .35-.15.35-.35V25.4h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34v-5.6h5.34c.2 0 .35-.16.35-.35v-4.9c0-.2-.16-.35-.35-.35h-5.34V3.35c0-.2-.16-.35-.35-.35H.36c-.2 0-.36.16-.36.35v4.9c0 .2.16.35.36.35Z"/></svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 451 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" id="图层_1" x="0" y="0" version="1.1" viewBox="0 0 519.27 214.14"><style>.st2{fill:#a9cc54}</style><path d="M493.54 171.25c-35.63 0-64.52-28.89-64.52-64.52s28.89-64.52 64.52-64.52c3.85 0 7.62.36 11.29 1L493.54 5.45H104.46v.02C49.24 6.51 4.78 51.59 4.78 107.07c0 55.47 44.45 100.56 99.68 101.59v.02h389.07l11.29-38.44c-3.66.65-7.43 1.01-11.28 1.01" style="fill:#b6de64"/><defs><path id="SVGID_1_" d="M493.54 171.25c-35.63 0-64.52-28.89-64.52-64.52s28.89-64.52 64.52-64.52c3.85 0 7.62.36 11.29 1V5.45H104.46v.02C49.24 6.51 4.78 51.59 4.78 107.07c0 55.47 44.45 100.56 99.68 101.59v.02h389.07l11.29-38.44c-3.66.65-7.43 1.01-11.28 1.01"/></defs><clipPath id="SVGID_2_"><use xlink:href="#SVGID_1_" style="overflow:visible"/></clipPath><g style="clip-path:url(#SVGID_2_)"><path d="M-23.16 55.58H428.5v51.1H-23.16zM-23.16 157.59h530.98v72.35H-23.16z" class="st2"/></g><path d="M493.54 184.23c-43.94 0-79.56-33.62-79.56-77.56s35.62-76.56 79.56-76.56" style="fill:none;stroke:#ddf4a4;stroke-width:49;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10"/></svg>
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#37814A" d="M2.303 0A2.298 2.298 0 0 0 0 2.303v19.394A2.298 2.298 0 0 0 2.303 24h19.394A2.298 2.298 0 0 0 24 21.697V2.303A2.298 2.298 0 0 0 21.697 0zm8.177 3.072c4.098 0 7.028 1.438 7.68 1.764l-1.194 2.55c-2.442-1.057-4.993-1.41-5.672-1.41-1.574 0-2.17.922-2.17 1.763v8.494c0 .869.596 1.791 2.17 1.791.679 0 3.23-.38 5.672-1.41l1.194 2.496c-.435.271-3.637 1.818-7.68 1.818-1.112 0-4.64-.244-4.64-4.64V7.713c0-4.397 3.528-4.64 4.64-4.64z"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 529 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

View File

@@ -1,5 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="lucide/github">
<path id="Vector" d="M15 22V18C15.1391 16.7473 14.7799 15.4901 14 14.5C17 14.5 20 12.5 20 9C20.08 7.75 19.73 6.52 19 5.5C19.28 4.35 19.28 3.15 19 2C19 2 18 2 16 3.5C13.36 3 10.64 3 8 3.5C6 2 5 2 5 2C4.7 3.15 4.7 4.35 5 5.5C4.27187 6.51588 3.91847 7.75279 4 9C4 12.5 7 14.5 10 14.5C9.61 14.99 9.32 15.55 9.15 16.15C8.98 16.75 8.93 17.38 9 18M9 18V22M9 18C4.49 20 4 16 2 16" stroke="#C0C1C3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector" d="M15 22V18C15.1391 16.7473 14.7799 15.4901 14 14.5C17 14.5 20 12.5 20 9C20.08 7.75 19.73 6.52 19 5.5C19.28 4.35 19.28 3.15 19 2C19 2 18 2 16 3.5C13.36 3 10.64 3 8 3.5C6 2 5 2 5 2C4.7 3.15 4.7 4.35 5 5.5C4.27187 6.51588 3.91847 7.75279 4 9C4 12.5 7 14.5 10 14.5C9.61 14.99 9.32 15.55 9.15 16.15C8.98 16.75 8.93 17.38 9 18M9 18V22M9 18C4.49 20 4 16 2 16" stroke="#7A8291" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 587 B

After

Width:  |  Height:  |  Size: 587 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#FFEC6E" d="m0 0 11.955 24L24 0zm13.366 4.827h1.393v1.38h-1.393zm-2.77 5.569H9.22V8.993h1.389zm0-2.087H9.22V6.906h1.389zm0-2.086H9.22V4.819h1.389zm2.087 6.263h-1.377V11.08h1.388zm0-2.09h-1.377V8.993h1.388zm0-2.087h-1.377V6.906h1.388zm0-2.086h-1.377V4.819h1.388zm.683.683h1.393v1.389h-1.393zm0 3.475V8.993h1.389v1.388Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#A68400" d="m0 0 11.955 24L24 0zm13.366 4.827h1.393v1.38h-1.393zm-2.77 5.569H9.22V8.993h1.389zm0-2.087H9.22V6.906h1.389zm0-2.086H9.22V4.819h1.389zm2.087 6.263h-1.377V11.08h1.388zm0-2.09h-1.377V8.993h1.388zm0-2.087h-1.377V6.906h1.388zm0-2.086h-1.377V4.819h1.388zm.683.683h1.393v1.389h-1.393zm0 3.475V8.993h1.389v1.388Z"/></svg>

Before

Width:  |  Height:  |  Size: 398 B

After

Width:  |  Height:  |  Size: 398 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="285" height="285" preserveAspectRatio="xMidYMid" viewBox="-14.5 0 285 285"><path fill="#430098" d="M230.4 0c13.996 0 25.37 11.232 25.597 25.177l.003.423v233.244c0 13.996-11.235 25.37-25.177 25.597l-.423.003H25.6c-13.996 0-25.37-11.235-25.597-25.176L0 258.844V25.6C0 11.601 11.235.23 25.177.003L25.6 0zm0 14.222H25.6c-6.174 0-11.218 4.948-11.374 11.085l-.004.293v233.244c0 6.174 4.948 11.218 11.085 11.374l.293.004h204.8c6.177 0 11.218-4.947 11.374-11.084l.004-.294V25.6c0-6.272-5.102-11.378-11.378-11.378M64 184.89l32 28.444-32 28.445zM92.444 42.667v80.75c14.201-4.622 34.052-9.64 53.334-9.64 17.582 0 28.106 6.913 33.838 12.712 12.109 12.25 12.397 27.82 12.381 29.897v.058l.003 85.334h-28.444v-84.942c-.14-6.639-3.34-14.614-17.778-14.614-28.632 0-60.714 14.257-61.666 14.683l-.02.009L64 166.016V42.666zm99.556 0c-1.92 16.156-8.47 31.644-21.333 46.222h-28.445c11.179-14.667 18.198-30.101 21.334-46.222Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="285" height="285" preserveAspectRatio="xMidYMid" viewBox="-14.5 0 285 285"><path fill="#7C4DD6" d="M230.4 0c13.996 0 25.37 11.232 25.597 25.177l.003.423v233.244c0 13.996-11.235 25.37-25.177 25.597l-.423.003H25.6c-13.996 0-25.37-11.235-25.597-25.176L0 258.844V25.6C0 11.601 11.235.23 25.177.003L25.6 0zm0 14.222H25.6c-6.174 0-11.218 4.948-11.374 11.085l-.004.293v233.244c0 6.174 4.948 11.218 11.085 11.374l.293.004h204.8c6.177 0 11.218-4.947 11.374-11.084l.004-.294V25.6c0-6.272-5.102-11.378-11.378-11.378M64 184.89l32 28.444-32 28.445zM92.444 42.667v80.75c14.201-4.622 34.052-9.64 53.334-9.64 17.582 0 28.106 6.913 33.838 12.712 12.109 12.25 12.397 27.82 12.381 29.897v.058l.003 85.334h-28.444v-84.942c-.14-6.639-3.34-14.614-17.778-14.614-28.632 0-60.714 14.257-61.666 14.683l-.02.009L64 166.016V42.666zm99.556 0c-1.92 16.156-8.47 31.644-21.333 46.222h-28.445c11.179-14.667 18.198-30.101 21.334-46.222Z"/></svg>

Before

Width:  |  Height:  |  Size: 958 B

After

Width:  |  Height:  |  Size: 958 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 463 B

View File

@@ -1 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LiveKit</title><path d="M14 10h-4v4h4v-4zM18 6h-4v4.001h4v-4zM18 14h-4v4h4v-4zM22 2h-4v4h4V2zM22 18h-4v4h4v-4z" fill="#1FD5F9"></path><path d="M6 18V2H2v20h12v-4H6z" fill="#fff"></path></svg>
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M14 10h-4v4h4v-4zM18 6h-4v4.001h4v-4zM18 14h-4v4h4v-4zM22 2h-4v4h4V2zM22 18h-4v4h4v-4z" fill="#1FD5F9"></path><path d="M6 18V2H2v20h12v-4H6z" fill="#7A8291"></path></svg>

Before

Width:  |  Height:  |  Size: 316 B

After

Width:  |  Height:  |  Size: 251 B

View File

@@ -1 +1 @@
<svg width='234' height='42' viewBox='0 0 234 42' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M18 30H6V18H18V30Z' fill='#CFCECD'/><path d='M18 12H6V30H18V12ZM24 36H0V6H24V36Z' fill='#656363'/><path d='M48 30H36V18H48V30Z' fill='#CFCECD'/><path d='M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z' fill='#656363'/><path d='M84 24V30H66V24H84Z' fill='#CFCECD'/><path d='M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z' fill='#656363'/><path d='M108 36H96V18H108V36Z' fill='#CFCECD'/><path d='M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z' fill='#656363'/><path d='M144 30H126V18H144V30Z' fill='#CFCECD'/><path d='M144 12H126V30H144V36H120V6H144V12Z' fill='#211E1E'/><path d='M168 30H156V18H168V30Z' fill='#CFCECD'/><path d='M168 12H156V30H168V12ZM174 36H150V6H174V36Z' fill='#211E1E'/><path d='M198 30H186V18H198V30Z' fill='#CFCECD'/><path d='M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z' fill='#211E1E'/><path d='M234 24V30H216V24H234Z' fill='#CFCECD'/><path d='M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z' fill='#211E1E'/></svg>
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#7A8291" d="M22 24H2V0h20zM17 4.8H7v14.4h10z"/></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 137 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>Render</title><path d="M18.263.007c-3.121-.147-5.744 2.109-6.192 5.082-.018.138-.045.272-.067.405-.696 3.703-3.936 6.507-7.827 6.507a7.9 7.9 0 0 1-3.825-.979.202.202 0 0 0-.302.178V24H12v-8.999c0-1.656 1.338-3 2.987-3h2.988c3.382 0 6.103-2.817 5.97-6.244-.12-3.084-2.61-5.603-5.682-5.75"/></svg>
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#7A8291" d="M18.263.007c-3.121-.147-5.744 2.109-6.192 5.082-.018.138-.045.272-.067.405-.696 3.703-3.936 6.507-7.827 6.507-1.388 0-2.691-.356-3.825-.979a.2024.2024 0 0 0-.302.178V24H12v-8.999c0-1.656 1.338-3 2.987-3h2.988c3.382 0 6.103-2.817 5.97-6.244-.12-3.084-2.61-5.603-5.682-5.75"/></svg>

Before

Width:  |  Height:  |  Size: 362 B

After

Width:  |  Height:  |  Size: 376 B

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" version="1.0" viewBox="0 0 800 600"><path d="M364.7 246.1h16.9L363.2 300h-16.9l-18.5-53.9h17.1l10.2 33h.2zM436 300h-39.4v-53.9h39.2v13.6h-23.6v7.1h20.9V279h-20.9v7.4H436zm41.6-54.8c11.3 0 19.6 5.8 22.1 15.1l-14.7 6.4c-.7-4.2-2.9-7.4-7.6-7.4-5.3 0-9.3 4.4-9.3 14s3.8 14.2 9.3 14.2c4.5 0 6.9-3.4 7.6-7.6l14.7 6.4c-2.5 9.3-10.9 15.1-22.1 15.1-16.2 0-26.1-10.7-26.1-28s9.9-28.2 26.1-28.2m50.3 54.8v-40.3h-16.2v-13.6h48.5v13.6H544V300zm97.7-26.9c0 17.2-10 28-26.1 28-16 0-26.1-10.7-26.1-28s10.2-28 26.1-28c16.1.1 26.1 10.8 26.1 28m-35.4 0c0 8.7 3.3 14.2 9.4 14.2s9.4-5.3 9.4-14.2c0-8.7-3.3-14.2-9.4-14.2s-9.4 5.3-9.4 14.2m84.4 26.9c-1.5-1.8-2-3.4-2.5-6.4l-1.1-6c-.7-3.8-2.5-4.9-5.8-4.9h-6.4V300h-16v-53.9h25.6c12.2 0 20.7 4.7 20.7 16.5 0 7.8-5.3 12.3-11.1 13.4v.2c4.7.7 7.4 3.6 8.5 8.5l2 8.5c.7 2.9 1.6 4.7 3.4 6.7.1.1-17.3.1-17.3.1m-8.2-40.7h-7.8V270h7.8c3.8 0 5.8-1.6 5.8-5.4 0-3.5-1.8-5.3-5.8-5.3m-321.5 81.2c3.3.4 5.6 2.4 5.6 6 0 4.4-2.7 7.3-8.7 7.3h-9.3v-26.1h8.9c5.1 0 8.2 2.4 8.2 6.9 0 3.1-2 5.3-4.7 5.9m-9.1-1.1h5.4c3.3 0 5.1-1.5 5.1-4.5s-1.8-4.5-5.1-4.5h-5.4zm0 11.6h5.8c3.6 0 5.4-1.6 5.4-4.5s-2-4.5-5.4-4.5h-5.8zm28-10.7 7.1-12.7h3.4l-8.9 15.8v10.2h-3.3v-10.2l-9.1-15.8h3.6zm26.7-12.7h7.6c7.3 0 12.7 4 12.7 12.9s-5.4 13.1-12.7 13.1h-7.6zm3.2 23.2h4.4c4.7 0 9.3-2.5 9.3-10.3s-4.5-10.2-9.3-10.2h-4.4zm36.2-4.3H419l-2.5 7.3h-3.3l9.4-26.1h3.6l9.4 26.1h-3.3zm-10-2.9h8.9l-4.4-12.5zm24.1 10.1v-23.2h-8.7v-2.9h20.9v2.9h-8.7v23.2zm28.3-7.2h-10.9l-2.5 7.3h-3.3l9.4-26.1h3.6l9.4 26.1h-3.3zm-9.8-2.9h8.9l-4.4-12.5zm20.7-16h7.6c7.3 0 12.7 4 12.7 12.9s-5.4 13.1-12.7 13.1h-7.6zm3.3 23.2h4.4c4.7 0 9.3-2.5 9.3-10.3s-4.5-10.2-9.3-10.2h-4.4zm44.1-10.1c0 8.4-4.5 13.4-11.3 13.4S508 349 508 340.7c0-8.4 4.5-13.4 11.3-13.4s11.3 5 11.3 13.4m-19 0c0 6.4 2.7 10.7 7.8 10.7s7.8-4.4 7.8-10.7c0-6.4-2.7-10.7-7.8-10.7s-7.8 4.3-7.8 10.7m45-.2v13.1H554l-.5-3.8c-.9 2.7-3.8 4.4-7.3 4.4-6.5 0-11.1-4.9-11.1-13.4 0-8.7 4.9-13.6 11.4-13.6 4.7 0 8 2 9.4 6l-2.9 1.6c-.9-3.1-3.4-4.9-6.7-4.9-5.1 0-8 4-8 10.7 0 6.4 2.5 10.7 8 10.7 3.4 0 6.7-1.8 6.7-6.5V343h-6.4v-2.7h9.8z"/><linearGradient id="a" x1="128.046" x2="313.226" y1="579.207" y2="472.293" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m199.7 408.2-92.2-54.1V245.9l92.2-54.1 92.2 54.1v108.2zm-79.4-61.7 79.1 46.5 79.1-46.5v-92.9l-79-46.5-79.3 46.5c.1-.1.1 92.9.1 92.9" style="fill:url(#a)"/><linearGradient id="b" x1="185.354" x2="277.202" y1="542.013" y2="488.984" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m239.4 270.6-32.3 56.8 10.5 18.5 42.8-75.3zm-36.1 0L171 327.4l10.5 18.5 42.8-75.3zm-64.4 0 42.8 75.3 10.5-18.5-32.3-56.8zm0 0 42.8 75.3 10.5-18.5-32.3-56.8zm64.4 0L171 327.4l10.5 18.5 42.8-75.3z" style="fill:url(#b)"/></svg>
<svg role="img" viewBox="89.7 190 220 220" xmlns="http://www.w3.org/2000/svg"><linearGradient id="a" x1="128.046" x2="313.226" y1="579.207" y2="472.293" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m199.7 408.2-92.2-54.1V245.9l92.2-54.1 92.2 54.1v108.2zm-79.4-61.7 79.1 46.5 79.1-46.5v-92.9l-79-46.5-79.3 46.5c.1-.1.1 92.9.1 92.9" style="fill:url(#a)"/><linearGradient id="b" x1="185.354" x2="277.202" y1="542.013" y2="488.984" gradientTransform="translate(-20.96 -225.764)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#10e7ff"/><stop offset=".103" style="stop-color:#33d5ff"/><stop offset=".255" style="stop-color:#60bdff"/><stop offset=".408" style="stop-color:#86aaff"/><stop offset=".56" style="stop-color:#a39aff"/><stop offset=".71" style="stop-color:#b790ff"/><stop offset=".858" style="stop-color:#c489ff"/><stop offset="1" style="stop-color:#c887ff"/></linearGradient><path d="m239.4 270.6-32.3 56.8 10.5 18.5 42.8-75.3zm-36.1 0L171 327.4l10.5 18.5 42.8-75.3zm-64.4 0 42.8 75.3 10.5-18.5-32.3-56.8zm0 0 42.8 75.3 10.5-18.5-32.3-56.8zm64.4 0L171 327.4l10.5 18.5 42.8-75.3z" style="fill:url(#b)"/></svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,12 +1,8 @@
import { useQuery } from 'react-query';
import { Button, Tooltip } from 'antd';
import getAllChannels from 'api/channels/getAll';
import classNames from 'classnames';
import { ChartLine } from '@signozhq/icons';
import { SuccessResponseV2 } from 'types/api';
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { useCreateAlertState } from '../context';
import AdvancedOptions from '../EvaluationSettings/AdvancedOptions';
@@ -21,14 +17,11 @@ function AlertCondition(): JSX.Element {
const { alertType, setAlertType } = useCreateAlertState();
const {
data,
data: channels,
isLoading: isLoadingChannels,
isError: isErrorChannels,
refetch: refreshChannels,
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const channels = data?.data || [];
} = useChannelOptions();
const showMultipleTabs =
alertType === AlertTypes.ANOMALY_BASED_ALERT ||

View File

@@ -2,12 +2,12 @@ import { QueryClient, QueryClientProvider } from 'react-query';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Channels } from 'types/api/channels/getAll';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import { CreateAlertProvider } from '../../context';
import AlertThreshold from '../AlertThreshold';
const mockChannels: Channels[] = [];
const mockChannels: ChannelOption[] = [];
const mockRefreshChannels = jest.fn();
const mockIsLoadingChannels = false;
const mockIsErrorChannels = false;
@@ -85,16 +85,17 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
}));
// Mock getAllChannels API
jest.mock('api/channels/getAll', () => ({
jest.mock('hooks/notificationChannels/useChannelOptions', () => ({
__esModule: true,
default: jest.fn(() =>
Promise.resolve({
data: [
{ id: '1', name: 'Email Channel' },
{ id: '2', name: 'Slack Channel' },
] as Channels[],
}),
),
useChannelOptions: jest.fn(() => ({
data: [
{ id: '1', name: 'Email Channel' },
{ id: '2', name: 'Slack Channel' },
] as ChannelOption[],
isLoading: false,
isError: false,
refetch: jest.fn(),
})),
}));
// Mock alert format categories

View File

@@ -3,7 +3,7 @@ import type { DefaultOptionType } from 'antd/es/select';
import { createMockAlertContextState } from 'container/CreateAlertV2/EvaluationSettings/__tests__/testUtils';
import { getAppContextMockState } from 'container/RoutingPolicies/__tests__/testUtils';
import * as appHooks from 'providers/App/App';
import { Channels } from 'types/api/channels/getAll';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import * as context from '../../context';
import ThresholdItem from '../ThresholdItem';
@@ -57,7 +57,7 @@ const mockThreshold = {
color: '#ff0000',
};
const mockChannels: Channels[] = [
const mockChannels: ChannelOption[] = [
{
id: TEST_CONSTANTS.CHANNEL_1,
name: TEST_CONSTANTS.EMAIL_CHANNEL_NAME,

View File

@@ -1,5 +1,5 @@
import type { DefaultOptionType } from 'antd/es/select';
import { Channels } from 'types/api/channels/getAll';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import {
NotificationSettingsAction,
@@ -21,7 +21,7 @@ export interface ThresholdItemProps {
updateThreshold: UpdateThreshold;
removeThreshold: (thresholdId: string) => void;
showRemoveButton: boolean;
channels: Channels[];
channels: ChannelOption[];
isLoadingChannels: boolean;
units: DefaultOptionType[];
isErrorChannels: boolean;
@@ -29,7 +29,7 @@ export interface ThresholdItemProps {
}
export interface AnomalyAndThresholdProps {
channels: Channels[];
channels: ChannelOption[];
isLoadingChannels: boolean;
isErrorChannels: boolean;
refreshChannels: () => void;

View File

@@ -1,19 +1,16 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { Plus } from '@signozhq/icons';
import { Button, Flex, Form, Select, Tooltip } from 'antd';
import { Switch } from '@signozhq/ui/switch';
import getAll from 'api/channels/getAll';
import logEvent from 'api/common/logEvent';
import { ALERTS_DATA_SOURCE_MAP } from 'constants/alerts';
import ROUTES from 'constants/routes';
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
import useComponentPermission from 'hooks/useComponentPermission';
import { useAppContext } from 'providers/App/App';
import { SuccessResponseV2 } from 'types/api';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef, Labels } from 'types/api/alerts/def';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { requireErrorMessage } from 'utils/form/requireErrorMessage';
import { openInNewTab } from 'utils/navigation';
@@ -47,12 +44,13 @@ function BasicInfo({
}: BasicInfoProps): JSX.Element {
const { t } = useTranslation('alerts');
const { isLoading, data, error, isError, refetch } = useQuery<
SuccessResponseV2<Channels[]>,
APIError
>(['getChannels'], {
queryFn: () => getAll(),
});
const {
isLoading,
data: channels,
error,
isError,
refetch,
} = useChannelOptions();
const { user } = useAppContext();
const [addNewChannelPermission] = useComponentPermission(
@@ -81,7 +79,7 @@ function BasicInfo({
});
};
const noChannels = data?.data?.length === 0;
const noChannels = channels.length === 0;
const handleCreateNewChannels = useCallback(() => {
logEvent('Alert: Create notification channel button clicked', {
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
@@ -96,7 +94,7 @@ function BasicInfo({
if (!isLoading && isNewRule && !hasLoggedEvent.current) {
logEvent('Alert: New alert creation page visited', {
dataSource: ALERTS_DATA_SOURCE_MAP[alertDef?.alertType as AlertTypes],
numberOfChannels: data?.data?.length,
numberOfChannels: channels.length,
});
hasLoggedEvent.current = true;
}
@@ -230,7 +228,7 @@ function BasicInfo({
disabled={shouldBroadCastToAllChannels}
currentValue={alertDef.preferredChannels}
handleCreateNewChannels={handleCreateNewChannels}
channels={data?.data || []}
channels={channels}
isLoading={isLoading}
hasError={isError}
error={error as APIError}

View File

@@ -2,10 +2,10 @@ import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus } from '@signozhq/icons';
import { Select, Spin } from 'antd';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import useComponentPermission from 'hooks/useComponentPermission';
import { useNotifications } from 'hooks/useNotifications';
import { useAppContext } from 'providers/App/App';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import { StyledCreateChannelOption, StyledSelect } from './styles';
@@ -16,7 +16,7 @@ export interface ChannelSelectProps {
onSelectChannels: (s: string[]) => void;
onDropdownOpen: () => void;
isLoading: boolean;
channels: Channels[];
channels: ChannelOption[];
hasError: boolean;
error: APIError;
handleCreateNewChannels: () => void;

View File

@@ -560,7 +560,7 @@ const onboardingConfigWithLinks = [
key: 'springboot',
label: 'Spring Boot',
imgUrl: springbootUrl,
link: '/docs/instrumentation/opentelemetry-springboot/',
link: '/docs/instrumentation/java/opentelemetry-java/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -570,25 +570,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/opentelemetry-springboot/',
link: '/docs/instrumentation/java/opentelemetry-java/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/opentelemetry-springboot/',
link: '/docs/instrumentation/java/opentelemetry-java/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/opentelemetry-springboot/',
link: '/docs/instrumentation/java/opentelemetry-java/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/opentelemetry-springboot/',
link: '/docs/instrumentation/java/opentelemetry-java/',
},
],
},
@@ -597,7 +597,7 @@ const onboardingConfigWithLinks = [
key: 'tomcat',
label: 'Tomcat',
imgUrl: tomcatUrl,
link: '/docs/instrumentation/opentelemetry-tomcat/',
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -607,25 +607,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/opentelemetry-tomcat/',
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/opentelemetry-tomcat/',
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/opentelemetry-tomcat/',
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/opentelemetry-tomcat/',
link: '/docs/instrumentation/java/opentelemetry-tomcat/',
},
],
},
@@ -634,7 +634,7 @@ const onboardingConfigWithLinks = [
key: 'jboss',
label: 'JBoss/WildFly',
imgUrl: jbossUrl,
link: '/docs/instrumentation/opentelemetry-jboss/',
link: '/docs/instrumentation/java/opentelemetry-jboss/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -644,25 +644,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/opentelemetry-jboss/',
link: '/docs/instrumentation/java/opentelemetry-jboss/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/opentelemetry-jboss/',
link: '/docs/instrumentation/java/opentelemetry-jboss/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/opentelemetry-jboss/',
link: '/docs/instrumentation/java/opentelemetry-jboss/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/opentelemetry-jboss/',
link: '/docs/instrumentation/java/opentelemetry-jboss/',
},
],
},
@@ -671,7 +671,7 @@ const onboardingConfigWithLinks = [
key: 'opentelemetry-quarkus',
label: 'Quarkus',
imgUrl: quarkusUrl,
link: '/docs/instrumentation/opentelemetry-quarkus/',
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -681,25 +681,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/opentelemetry-quarkus/',
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/opentelemetry-quarkus/',
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/opentelemetry-quarkus/',
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/opentelemetry-quarkus/',
link: '/docs/instrumentation/java/opentelemetry-quarkus/',
},
],
},
@@ -708,7 +708,7 @@ const onboardingConfigWithLinks = [
key: 'Others',
label: 'Others',
imgUrl: javaOthersUrl,
link: '/docs/instrumentation/opentelemetry-java/',
link: '/docs/instrumentation/java/opentelemetry-java/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -718,25 +718,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/opentelemetry-java/',
link: '/docs/instrumentation/java/opentelemetry-java/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/opentelemetry-java/',
link: '/docs/instrumentation/java/opentelemetry-java/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/opentelemetry-java/',
link: '/docs/instrumentation/java/opentelemetry-java/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/opentelemetry-java/',
link: '/docs/instrumentation/java/opentelemetry-java/',
},
],
},
@@ -872,7 +872,7 @@ const onboardingConfigWithLinks = [
key: 'nodejs-nestjs',
label: 'NodeJs/NestJS',
imgUrl: nodejsUrl,
link: '/docs/instrumentation/opentelemetry-javascript/',
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -882,25 +882,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/opentelemetry-javascript/',
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/opentelemetry-javascript/',
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/opentelemetry-javascript/',
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/opentelemetry-javascript/',
link: '/docs/instrumentation/javascript/opentelemetry-nodejs/',
},
],
},
@@ -909,7 +909,7 @@ const onboardingConfigWithLinks = [
key: 'nextjs',
label: 'NextJS',
imgUrl: nextjsUrl,
link: '/docs/instrumentation/opentelemetry-nextjs/',
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -919,25 +919,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/opentelemetry-nextjs/',
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/opentelemetry-nextjs/',
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/opentelemetry-nextjs/',
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/opentelemetry-nextjs/',
link: '/docs/instrumentation/javascript/opentelemetry-nextjs/',
},
],
},
@@ -946,7 +946,7 @@ const onboardingConfigWithLinks = [
key: 'nuxtjs',
label: 'NuxtJS',
imgUrl: nuxtjsUrl,
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -956,25 +956,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/opentelemetry-nuxtjs/',
link: '/docs/instrumentation/javascript/opentelemetry-nuxtjs/',
},
],
},
@@ -983,7 +983,7 @@ const onboardingConfigWithLinks = [
key: 'react-native',
label: 'React Native',
imgUrl: reactjsUrl,
link: '/docs/instrumentation/opentelemetry-react-native/',
link: '/docs/instrumentation/javascript/opentelemetry-react-native/',
},
],
},
@@ -1298,7 +1298,7 @@ const onboardingConfigWithLinks = [
'resque',
],
id: 'ruby',
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
link: '/docs/instrumentation/opentelemetry-ruby/',
question: {
desc: 'What is your Environment?',
type: 'select',
@@ -1308,25 +1308,25 @@ const onboardingConfigWithLinks = [
key: 'vm',
label: 'VM',
imgUrl: vmUrl,
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
link: '/docs/instrumentation/opentelemetry-ruby/',
},
{
key: 'k8s',
label: 'Kubernetes',
imgUrl: kubernetesUrl,
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
link: '/docs/instrumentation/opentelemetry-ruby/',
},
{
key: 'docker',
label: 'Docker',
imgUrl: dockerUrl,
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
link: '/docs/instrumentation/opentelemetry-ruby/',
},
{
key: 'windows',
label: 'Windows',
imgUrl: windowsUrl,
link: '/docs/instrumentation/opentelemetry-ruby-on-rails/',
link: '/docs/instrumentation/opentelemetry-ruby/',
},
],
},
@@ -2292,7 +2292,7 @@ const onboardingConfigWithLinks = [
'python structured logging',
'python-logs-auto-instrumentation',
],
link: '/docs/userguide/python-logs-auto-instrumentation/',
link: '/docs/logs-management/send-logs/python-logs/',
},
{
dataSource: 'otel-java-sdk',
@@ -2323,7 +2323,7 @@ const onboardingConfigWithLinks = [
'Logback',
'log4j',
],
link: '/docs/userguide/collecting_application_logs_otel_sdk_java/',
link: '/docs/logs-management/send-logs/java-logs/',
},
{
dataSource: 'nodejs-logs',
@@ -2463,7 +2463,7 @@ const onboardingConfigWithLinks = [
'linux',
],
imgUrl: hostmetricsUrl,
link: '/docs/userguide/hostmetrics/',
link: '/docs/infrastructure-monitoring/user-guides/hostmetrics/',
},
{
dataSource: 'k8s-metrics',
@@ -2495,7 +2495,7 @@ const onboardingConfigWithLinks = [
'telemetry',
],
imgUrl: kubernetesUrl,
link: '/docs/userguide/k8s-metrics/',
link: '/docs/infrastructure-monitoring/user-guides/k8s-metrics/',
},
{
dataSource: 'docker-container-metrics',
@@ -2583,7 +2583,7 @@ const onboardingConfigWithLinks = [
key: 'aws-ec2-metrics-manual',
label: 'Manual Setup',
imgUrl: ec2Url,
link: '/docs/tutorial/opentelemetry-binary-usage-in-virtual-machine/',
link: '/docs/opentelemetry-collection-agents/vm/install/',
},
],
},
@@ -2772,7 +2772,7 @@ const onboardingConfigWithLinks = [
'vpc-logs',
],
imgUrl: vpcUrl,
link: '/docs/aws-monitoring/vpc-logs/',
link: '/docs/aws-monitoring/vpc/',
},
{
dataSource: 'aws-rds',
@@ -3837,7 +3837,7 @@ const onboardingConfigWithLinks = [
'traces',
'tracing',
],
link: '/docs/llm/opentelemetry-openai-monitoring/',
link: '/docs/openai-monitoring/',
},
{
dataSource: 'codex-monitoring',
@@ -4779,7 +4779,7 @@ const onboardingConfigWithLinks = [
link: '/docs/instrumentation/mobile-instrumentation/opentelemetry-flutter/',
},
{
dataSource: 'swift',
dataSource: 'ios-swift',
label: 'iOS Swift',
imgUrl: swiftMonitoringUrl,
tags: ['mobile app monitoring'],
@@ -4808,6 +4808,7 @@ const onboardingConfigWithLinks = [
'traces',
'tracing',
],
id: 'ios-swift',
link: '/docs/instrumentation/mobile-instrumentation/opentelemetry-swiftui/',
},
{
@@ -5473,7 +5474,7 @@ const onboardingConfigWithLinks = [
'telemetry',
],
id: 'hasura',
link: '/docs/integrations/opentelemetry-hasura/',
link: '/docs/integrations/outposts/hasura/',
},
{
dataSource: 'opencode-observability',
@@ -5935,7 +5936,7 @@ const onboardingConfigWithLinks = [
'metrics',
'monitoring',
],
link: '/docs/integrations/flyio/',
link: '/docs/integrations/outposts/flyio/',
},
{
dataSource: 'envoy',
@@ -6513,7 +6514,7 @@ const onboardingConfigWithLinks = [
'tracing',
],
id: 'openclaw-observability',
link: '/docs/openclaw-monitoring/',
link: '/docs/openclaw-observability/',
},
{
dataSource: 'claude-agent-monitoring',
@@ -6555,7 +6556,7 @@ const onboardingConfigWithLinks = [
'render monitoring',
],
id: 'render-metrics',
link: '/docs/metrics-management/render-metrics/',
link: '/docs/integrations/outposts/render/',
},
{
dataSource: 'cert-manager',
@@ -6579,7 +6580,7 @@ const onboardingConfigWithLinks = [
'tls',
],
id: 'cert-manager',
link: '/docs/infrastructure-monitoring/cert-manager/',
link: '/docs/infrastructure-monitoring/user-guides/cert-manager/',
},
{
dataSource: 'pgbouncer',

View File

@@ -1,6 +1,6 @@
import { ApiRoutingPolicy } from 'api/routingPolicies/getRoutingPolicies';
import { IAppContext, IUser } from 'providers/App/types';
import { Channels } from 'types/api/channels/getAll';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
import { RoutingPolicy, UseRoutingPoliciesReturn } from '../types';
@@ -28,21 +28,13 @@ export const MOCK_ROUTING_POLICY_2: RoutingPolicy = {
updatedBy: 'user2@signoz.io',
};
export const MOCK_CHANNEL_1: Channels = {
export const MOCK_CHANNEL_1: ChannelOption = {
name: 'Channel 1',
created_at: '2021-01-01',
data: 'data 1',
id: '1',
type: 'type 1',
updated_at: '2021-01-01',
};
export const MOCK_CHANNEL_2: Channels = {
export const MOCK_CHANNEL_2: ChannelOption = {
name: 'Channel 2',
created_at: '2021-01-02',
data: 'data 2',
id: '2',
type: 'type 2',
updated_at: '2021-01-02',
};
export function getUseRoutingPoliciesMockData(

View File

@@ -77,12 +77,14 @@ jest.mock('hooks/routingPolicies/useDeleteRoutingPolicy', () => ({
isLoading: false,
}),
}));
jest.mock('api/channels/getAll', () => ({
jest.mock('hooks/notificationChannels/useChannelOptions', () => ({
__esModule: true,
default: (): any =>
Promise.resolve({
data: [MOCK_CHANNEL_1, MOCK_CHANNEL_2],
}),
useChannelOptions: (): any => ({
data: [MOCK_CHANNEL_1, MOCK_CHANNEL_2],
isLoading: false,
isError: false,
refetch: jest.fn(),
}),
}));
const ROUTING_POLICY_1_NAME = 'Routing Policy 1';

View File

@@ -1,4 +1,4 @@
import { Channels } from 'types/api/channels/getAll';
import { ChannelOption } from 'hooks/notificationChannels/useChannelOptions';
export interface RoutingPolicy {
id: string;
@@ -62,7 +62,7 @@ export interface RoutingPolicyDetailsProps {
routingPolicy: RoutingPolicy | null;
closeModal: () => void;
mode: PolicyDetailsModalMode;
channels: Channels[];
channels: ChannelOption[];
isErrorChannels: boolean;
isLoadingChannels: boolean;
handlePolicyDetailsModalAction: HandlePolicyDetailsModalAction;
@@ -86,7 +86,7 @@ export interface UseRoutingPoliciesReturn {
isErrorRoutingPolicies: boolean;
refetchRoutingPolicies: () => void;
// Channels
channels: Channels[];
channels: ChannelOption[];
isLoadingChannels: boolean;
isErrorChannels: boolean;
refreshChannels: () => void;

View File

@@ -1,19 +1,17 @@
import { useMemo, useState } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { useQueryClient } from 'react-query';
import { useHistory } from 'react-router-dom';
import { toast } from '@signozhq/ui/sonner';
import getAllChannels from 'api/channels/getAll';
import { GetRoutingPoliciesResponse } from 'api/routingPolicies/getRoutingPolicies';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useCreateRoutingPolicy } from 'hooks/routingPolicies/useCreateRoutingPolicy';
import { useDeleteRoutingPolicy } from 'hooks/routingPolicies/useDeleteRoutingPolicy';
import { useChannelOptions } from 'hooks/notificationChannels/useChannelOptions';
import { useGetRoutingPolicies } from 'hooks/routingPolicies/useGetRoutingPolicies';
import { useUpdateRoutingPolicy } from 'hooks/routingPolicies/useUpdateRoutingPolicy';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import useUrlQuery from 'hooks/useUrlQuery';
import { SuccessResponseV2 } from 'types/api';
import { Channels } from 'types/api/channels/getAll';
import APIError from 'types/api/error';
import {
PolicyDetailsModalMode,
@@ -83,14 +81,11 @@ function useRoutingPolicies(): UseRoutingPoliciesReturn {
// Channels list
const {
data,
data: channels,
isLoading: isLoadingChannels,
isError: isErrorChannels,
refetch: refetchChannels,
} = useQuery<SuccessResponseV2<Channels[]>, APIError>(['getChannels'], {
queryFn: () => getAllChannels(),
});
const channels = data?.data || [];
} = useChannelOptions();
const refreshChannels = (): void => {
refetchChannels();

View File

@@ -1,3 +1,4 @@
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
import { Widgets } from 'types/api/widgets/widget';
import {
MetricRangePayloadProps,
@@ -164,6 +165,27 @@ describe('BarPanel utils', () => {
expect(config.series).toHaveLength(3);
});
it('passes partial timestamps to the series', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [
[1000, '1'],
[1060, '0.2'],
],
partialTimestamps: [1060],
} as MetricRangePayloadProps['data']['result'][0],
]);
const series = prepareBarPanelConfig({
...baseParams,
apiResponse,
}).getConfig().series?.[1] as ExtendedSeries;
expect(series.partialTimestamps).toStrictEqual(new Set([1060]));
});
it('uses getLegend for label when currentQuery is provided', () => {
const apiResponse = createApiResponse([
{

View File

@@ -89,6 +89,9 @@ export function prepareBarPanelConfig({
isDarkMode,
stepInterval: currentStepInterval,
metric: series.metric,
partialTimestamps: series.partialTimestamps
? new Set(series.partialTimestamps)
: undefined,
});
});

View File

@@ -5,6 +5,7 @@ import {
} from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
import { PanelMode } from 'lib/visualization/panels/types';
import { prepareUPlotConfig } from 'container/WidgetCard/Panels/TimeSeriesPanel/utils';
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
@@ -185,6 +186,28 @@ describe('TimeSeriesPanel utils', () => {
});
});
it('passes partial timestamps to the series', () => {
const apiResponse = createApiResponse([
{
metric: {},
queryName: 'Q1',
values: [
[1000, '1'],
[1060, '0.2'],
],
partialTimestamps: [1060],
} as MetricRangePayloadProps['data']['result'][0],
]);
const series = prepareUPlotConfig({
...baseParams,
apiResponse,
currentQuery: null as unknown as Query,
}).getConfig().series?.[1] as ExtendedSeries;
expect(series.partialTimestamps).toStrictEqual(new Set([1060]));
});
it('uses getLegend for label when currentQuery is provided', () => {
const apiResponse = createApiResponse([
{

View File

@@ -101,6 +101,9 @@ export const prepareUPlotConfig = ({
fillMode: widget.fillMode || FillMode.None,
isDarkMode,
metric: series.metric,
partialTimestamps: series.partialTimestamps
? new Set(series.partialTimestamps)
: undefined,
});
});

View File

@@ -0,0 +1,68 @@
import { useQuery, UseQueryResult } from 'react-query';
import { listNotificationChannels } from 'api/generated/services/channels';
import {
AlertmanagertypesChannelListOrderDTO,
AlertmanagertypesChannelListSortDTO,
AlertmanagertypesListedNotificationChannelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { range } from 'lodash-es';
const PAGE_SIZE = 100;
export const CHANNEL_OPTIONS_QUERY_KEY = ['notificationChannelOptions'];
export interface ChannelOption {
id: string;
/** The display name, which is what rules and routing policies reference. */
name: string;
}
export type UseChannelOptionsResult = Omit<
UseQueryResult<ChannelOption[], Error>,
'data'
> & {
data: ChannelOption[];
};
function fetchPage(
offset: number,
): ReturnType<typeof listNotificationChannels> {
return listNotificationChannels({
limit: PAGE_SIZE,
offset,
sort: AlertmanagertypesChannelListSortDTO.name,
order: AlertmanagertypesChannelListOrderDTO.asc,
});
}
/**
* Every channel, for the pickers that let a rule or a policy name one. The list
* API is paginated and silently clamps a larger limit, so the first page reports
* the total and any remainder is fetched alongside rather than truncating. One
* request covers an org with a page's worth of channels or fewer.
*/
async function fetchAllChannels(): Promise<ChannelOption[]> {
const first = await fetchPage(0);
const rest = await Promise.all(
range(PAGE_SIZE, first.data.total, PAGE_SIZE).map(fetchPage),
);
const channels: AlertmanagertypesListedNotificationChannelDTO[] = [
...first.data.channels,
...rest.flatMap((page) => page.data.channels),
];
return channels.map((channel) => ({
id: channel.id,
name: channel.displayName,
}));
}
export function useChannelOptions(): UseChannelOptionsResult {
const query = useQuery<ChannelOption[], Error>(
CHANNEL_OPTIONS_QUERY_KEY,
fetchAllChannels,
);
return { ...query, data: query.data ?? [] };
}

View File

@@ -0,0 +1,42 @@
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
import { convertNewDataToOld } from '../convertNewDataToOld';
describe('convertNewDataToOld', () => {
it('carries partial values as epoch-second partialTimestamps', () => {
const payload = {
data: {
resultType: 'time_series',
result: [
{
queryName: 'A',
series: [
{
labels: {},
labelsArray: [],
values: [
{ timestamp: 1_000_000, value: '1' },
{ timestamp: 1_060_000, value: '0.2', partial: true },
],
},
{
labels: {},
labelsArray: [],
values: [{ timestamp: 1_000_000, value: '1' }],
},
],
},
],
},
} as unknown as MetricRangePayloadV3;
const [partial, complete] = convertNewDataToOld(payload).data.result;
expect(partial.values).toStrictEqual([
[1000, '1'],
[1060, '0.2'],
]);
expect(partial.partialTimestamps).toStrictEqual([1060]);
expect(complete).not.toHaveProperty('partialTimestamps');
});
});

View File

@@ -25,11 +25,16 @@ export const convertNewDataToOld = (
return [...acc, renderValues];
}, []);
const partialTimestamps = series.values
.filter((value) => value.partial)
.map((value) => value.timestamp / 1000);
const result: QueryData = {
metric: series.labels,
values,
queryName: `${item.queryName}`,
metaData: series.metaData,
...(partialTimestamps.length ? { partialTimestamps } : {}),
};
oldResult.push(result);

View File

@@ -122,6 +122,27 @@ describe('Tooltip', () => {
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
});
it('marks the header partial when a visible series has a partial bucket at the cursor', () => {
const withSeries = (series: unknown[]): uPlot =>
Object.assign(createUPlotInstance(0), { series });
const partial = { show: true, partialTimestamps: new Set([1]) };
const { unmount } = renderTooltip({
uPlotInstance: withSeries([{}, partial]),
});
expect(screen.getByTestId('uplot-tooltip-header-partial')).toHaveTextContent(
'(partial)',
);
unmount();
renderTooltip({
uPlotInstance: withSeries([{}, { ...partial, show: false }]),
});
expect(
screen.queryByTestId('uplot-tooltip-header-partial'),
).not.toBeInTheDocument();
});
it('drops the date from the header title for a point on the current day', () => {
const todayTimestamp = dayjs().tz('UTC').startOf('hour').unix();
const uPlotInstance = createUPlotInstance(0, todayTimestamp);

View File

@@ -13,6 +13,17 @@
gap: var(--spacing-2);
}
.headerEnd {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.partialLabel {
font-style: italic;
opacity: 0.7;
}
.pinnedItem {
padding: var(--spacing-4);
}

View File

@@ -5,6 +5,7 @@ import { useTimezone } from 'providers/Timezone';
import type uPlot from 'uplot';
import { formatTimestampOmittingTodaysDate } from 'utils/timeUtils';
import type { ExtendedSeries } from '../../../../config/types';
import { TooltipContentItem } from '../../../types';
import TooltipItem from '../TooltipItem/TooltipItem';
import TooltipPinnedBadge from '../TooltipPinnedBadge/TooltipPinnedBadge';
@@ -34,7 +35,7 @@ export default function TooltipHeader({
const { timezone: userTimezone } = useTimezone();
const resolvedTimezone = timezone?.value ?? userTimezone.value;
const headerTitle = useMemo(() => {
const header = useMemo(() => {
if (!showTooltipHeader) {
return null;
}
@@ -46,14 +47,21 @@ export default function TooltipHeader({
if (timestamp == null) {
return null;
}
return formatTimestampOmittingTodaysDate(
timestamp * 1000,
resolvedTimezone,
dateFormat,
);
return {
title: formatTimestampOmittingTodaysDate(
timestamp * 1000,
resolvedTimezone,
dateFormat,
),
isPartial: (uPlotInstance.series ?? []).some(
(series: ExtendedSeries) =>
series.show && series.partialTimestamps?.has(timestamp),
),
};
}, [
resolvedTimezone,
uPlotInstance.data,
uPlotInstance.series,
uPlotInstance.cursor.idx,
showTooltipHeader,
dateFormat,
@@ -64,10 +72,22 @@ export default function TooltipHeader({
className={Styles.headerContainer}
data-testid="uplot-tooltip-header-container"
>
{showTooltipHeader && headerTitle && (
{showTooltipHeader && header && (
<div className={cx(Styles.headerRow, headerRowClassName)}>
<span>{headerTitle}</span>
{isPinned && <TooltipPinnedBadge />}
<span>{header.title}</span>
{(header.isPartial || isPinned) && (
<span className={Styles.headerEnd}>
{header.isPartial && (
<span
className={Styles.partialLabel}
data-testid="uplot-tooltip-header-partial"
>
(partial)
</span>
)}
{isPinned && <TooltipPinnedBadge />}
</span>
)}
</div>
)}

View File

@@ -1,5 +1,6 @@
import { SeriesVisibilityItem } from 'lib/visualization/panels/types';
import { getStoredSeriesVisibility } from 'lib/visualization/panels/utils/legendVisibilityUtils';
import { partialPointsDrawHook } from 'lib/uPlotV2/hooks/partialPointsDrawHook';
import { ThresholdsDrawHookOptions } from 'lib/uPlotV2/hooks/types';
import { thresholdsDrawHook } from 'lib/uPlotV2/hooks/useThresholdsDrawHook';
import { resolveSeriesVisibility } from 'lib/uPlotV2/utils/seriesVisibility';
@@ -81,6 +82,8 @@ export class UPlotConfigBuilder extends ConfigBuilder<
private thresholds: Record<string, ThresholdsDrawHookOptions> = {};
private hasPartialPointsHook = false;
private tzDate: ((timestamp: number) => Date) | undefined;
private id: string;
@@ -177,6 +180,10 @@ export class UPlotConfigBuilder extends ConfigBuilder<
*/
addSeries(props: SeriesProps): void {
this.series.push(new UPlotSeriesBuilder(props));
if (props.partialTimestamps?.size && !this.hasPartialPointsHook) {
this.hasPartialPointsHook = true;
this.addHook('draw', partialPointsDrawHook);
}
}
getSeriesSpanGapsOptions(): SeriesSpanGapsOption[] {

View File

@@ -4,6 +4,7 @@ import { calculateWidthBasedOnStepInterval } from 'lib/uPlotV2/utils';
import uPlot, { Series } from 'uplot';
import { resolveFillOpacity, toAlphaHex } from '../utils/fillOpacity';
import { reportBarRect } from '../utils/barRect';
import { generateGradientFill } from '../utils/generateGradientFill';
import { isolatedPointFilter } from '../utils/seriesPointsFilter';
import {
@@ -218,7 +219,14 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
}
getConfig(): ExtendedSeries {
const { scaleKey, label, spanGaps, show = true, metric } = this.props;
const {
scaleKey,
label,
spanGaps,
show = true,
metric,
partialTimestamps,
} = this.props;
const resolvedLineColor = this.getLineColor();
@@ -246,6 +254,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<
...pathConfig,
points: Object.keys(pointsConfig).length > 0 ? pointsConfig : undefined,
metric,
...(partialTimestamps?.size ? { partialTimestamps } : {}),
};
}
}
@@ -358,6 +367,7 @@ function getBarPathBuilder({
builders[barsCfgKey] = barsPathBuilderFactory({
size: [barWidthFactor, effectiveBarMaxWidth],
align: barAlignment,
each: reportBarRect,
});
}
@@ -372,6 +382,7 @@ function getBarPathBuilder({
builders[barsCfgKey] = barsPathBuilderFactory({
size: [barWidthFactor, barMaxWidth],
align: barAlignment,
each: reportBarRect,
});
}

View File

@@ -650,4 +650,20 @@ describe('UPlotConfigBuilder stacking', () => {
expect(builder.getConfig().bands).toStrictEqual([{ series: [1, 3] }]);
});
it('registers the partial points draw hook once, only for series with partial timestamps', () => {
const props: SeriesProps = {
scaleKey: 'y',
label: 'Requests',
colorMapping: {},
drawStyle: DrawStyle.Line,
};
const builder = new UPlotConfigBuilder({ id: 'partial' });
builder.addSeries(props);
expect(builder.getConfig().hooks?.draw ?? []).toHaveLength(0);
builder.addSeries({ ...props, partialTimestamps: new Set([40]) });
builder.addSeries({ ...props, partialTimestamps: new Set([40]) });
expect(builder.getConfig().hooks?.draw).toHaveLength(1);
});
});

View File

@@ -1,6 +1,7 @@
import { themeColors } from 'constants/theme';
import uPlot from 'uplot';
import { reportBarRect } from '../../utils/barRect';
import { isolatedPointFilter } from '../../utils/seriesPointsFilter';
import type { SeriesProps } from '../types';
import { DrawStyle, FillMode, LineInterpolation, LineStyle } from '../types';
@@ -398,4 +399,29 @@ describe('UPlotSeriesBuilder', () => {
expect(builder.getConfig().fill).toBeUndefined();
});
it('exposes partial timestamps only when there are some', () => {
const partialTimestamps = new Set([40]);
expect(
new UPlotSeriesBuilder(createBaseProps({ partialTimestamps })).getConfig(),
).toHaveProperty('partialTimestamps', partialTimestamps);
expect(
new UPlotSeriesBuilder(
createBaseProps({ partialTimestamps: new Set() }),
).getConfig(),
).not.toHaveProperty('partialTimestamps');
});
it('reports bar rects from bar path builders', () => {
const config = new UPlotSeriesBuilder(
createBaseProps({ drawStyle: DrawStyle.Bar, barMaxWidth: 321 }),
).getConfig();
config.paths?.({} as uPlot, 1, 0, 1);
expect(uPlot.paths.bars).toHaveBeenCalledWith(
expect.objectContaining({ each: reportBarRect }),
);
});
});

View File

@@ -201,6 +201,7 @@ export enum FillMode {
export type ExtendedSeries = Series & {
metric?: { [key: string]: string };
partialTimestamps?: Set<number>;
};
export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
@@ -229,6 +230,8 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
isDarkMode?: boolean;
stepInterval?: number;
metric?: { [key: string]: string };
/** Epoch-second x values whose bucket doesn't cover a full step; marked with a dotted ring. */
partialTimestamps?: Set<number>;
}
export interface LegendItem {

View File

@@ -0,0 +1,77 @@
import uPlot from 'uplot';
import type { ExtendedSeries } from '../../config/types';
import { reportBarRect } from '../../utils/barRect';
import { partialPointsDrawHook } from '../partialPointsDrawHook';
function createPlot(
series: Partial<ExtendedSeries>,
values: (number | null)[] = [1, 2, 3],
): { u: uPlot; ctx: Record<string, jest.Mock> } {
const ctx = {
save: jest.fn(),
restore: jest.fn(),
beginPath: jest.fn(),
rect: jest.fn(),
clip: jest.fn(),
arc: jest.fn(),
fill: jest.fn(),
stroke: jest.fn(),
setLineDash: jest.fn(),
strokeRect: jest.fn(),
};
const xValues = [10, 20, 30];
const u = {
ctx,
bbox: { left: 0, top: 0, width: 100, height: 100 },
data: [xValues, values],
series: [{}, { show: true, stroke: 'red', points: { size: 4 }, ...series }],
valToIdx: (value: number): number => xValues.indexOf(value),
valToPos: (value: number): number => value,
} as unknown as uPlot;
return { u, ctx };
}
describe('partialPointsDrawHook', () => {
it('draws a dot and a dotted ring on each partial point', () => {
const { u, ctx } = createPlot({ partialTimestamps: new Set([30]) });
partialPointsDrawHook(u);
expect(ctx.arc).toHaveBeenCalledTimes(2);
expect(ctx.arc).toHaveBeenCalledWith(30, 3, 2, 0, 2 * Math.PI);
expect(ctx.arc).toHaveBeenCalledWith(30, 3, 5, 0, 2 * Math.PI);
expect(ctx.setLineDash).toHaveBeenCalledWith([2, 2]);
expect(ctx.restore).toHaveBeenCalled();
});
it('skips hidden series, null values and timestamps not in the data', () => {
const hidden = createPlot({ show: false, partialTimestamps: new Set([30]) });
const missing = createPlot({ partialTimestamps: new Set([30]) }, [
1,
2,
null,
]);
const unknown = createPlot({ partialTimestamps: new Set([40]) });
[hidden, missing, unknown].forEach(({ u, ctx }) => {
partialPointsDrawHook(u);
expect(ctx.arc).not.toHaveBeenCalled();
});
});
it('draws a dotted outline around a partial bar', () => {
const { u, ctx } = createPlot({
partialTimestamps: new Set([30]),
paths: (plot, seriesIdx, idx) => {
reportBarRect(plot, seriesIdx, idx, 10, 20, 8, 30);
return null;
},
});
partialPointsDrawHook(u);
expect(ctx.strokeRect).toHaveBeenCalledWith(7, 17, 14, 36);
expect(ctx.arc).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,89 @@
import uPlot from 'uplot';
import { ExtendedSeries } from '../config/types';
import { BarRect, measureBar } from '../utils/barRect';
const DEFAULT_POINT_SIZE = 5;
const RING_GAP = 3;
const RING_WIDTH = 1.5;
const RING_DASH = [2, 2];
function drawRing(
u: uPlot,
series: ExtendedSeries,
idx: number,
value: number,
): void {
const { ctx } = u;
const { pxRatio } = uPlot;
const cx = u.valToPos(u.data[0][idx], 'x', true);
const cy = u.valToPos(value, series.scale ?? 'y', true);
const radius = ((series.points?.size ?? DEFAULT_POINT_SIZE) / 2) * pxRatio;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, 2 * Math.PI);
ctx.fill();
ctx.lineWidth = RING_WIDTH * pxRatio;
ctx.setLineDash(RING_DASH.map((segment) => segment * pxRatio));
ctx.beginPath();
ctx.arc(cx, cy, radius + RING_GAP * pxRatio, 0, 2 * Math.PI);
ctx.stroke();
}
function drawBarOutline(u: uPlot, rect: BarRect): void {
const gap = RING_GAP * uPlot.pxRatio;
u.ctx.lineWidth = RING_WIDTH * uPlot.pxRatio;
u.ctx.setLineDash(RING_DASH.map((segment) => segment * uPlot.pxRatio));
u.ctx.strokeRect(
rect.left - gap,
rect.top - gap,
rect.width + 2 * gap,
rect.height + 2 * gap,
);
}
/**
* Marks each series' partial buckets: a dotted outline around bars, a dot and dotted
* ring on line and point series.
*/
export function partialPointsDrawHook(u: uPlot): void {
const { ctx, bbox } = u;
const xValues = u.data[0];
ctx.save();
ctx.beginPath();
ctx.rect(bbox.left, bbox.top, bbox.width, bbox.height);
ctx.clip();
for (let seriesIdx = 1; seriesIdx < u.series.length; seriesIdx += 1) {
const series = u.series[seriesIdx] as ExtendedSeries;
if (!series.show || !series.partialTimestamps) {
continue;
}
const color =
typeof series.stroke === 'function'
? series.stroke(u, seriesIdx)
: (series.stroke ?? '');
ctx.fillStyle = color;
ctx.strokeStyle = color;
ctx.globalAlpha = series.alpha ?? 1;
series.partialTimestamps.forEach((timestamp) => {
const idx = u.valToIdx(timestamp);
const value = u.data[seriesIdx][idx];
if (xValues[idx] !== timestamp || value == null) {
return;
}
ctx.setLineDash([]);
const bar = measureBar(u, seriesIdx, idx);
if (bar) {
drawBarOutline(u, bar);
} else {
drawRing(u, series, idx, value);
}
});
}
ctx.restore();
}

View File

@@ -0,0 +1,30 @@
import type uPlot from 'uplot';
import { measureBar, reportBarRect } from '../barRect';
function plotWithPaths(paths?: uPlot.Series['paths']): uPlot {
return { series: [{}, { paths }] } as unknown as uPlot;
}
describe('measureBar', () => {
it('returns the rect the bar paths report for that index', () => {
const paths = jest.fn((u: uPlot, seriesIdx: number, idx: number) => {
reportBarRect(u, seriesIdx, idx, 10, 20, 8, 30);
return null;
});
const u = plotWithPaths(paths);
expect(measureBar(u, 1, 4)).toStrictEqual({
left: 10,
top: 20,
width: 8,
height: 30,
});
expect(paths).toHaveBeenCalledWith(u, 1, 4, 4);
});
it('returns null for series whose paths report no bar', () => {
expect(measureBar(plotWithPaths(jest.fn(() => null)), 1, 0)).toBeNull();
expect(measureBar(plotWithPaths(), 1, 0)).toBeNull();
});
});

View File

@@ -0,0 +1,45 @@
import type uPlot from 'uplot';
export interface BarRect {
left: number;
top: number;
width: number;
height: number;
}
let onBarRect: ((rect: BarRect) => void) | null = null;
/** `each` callback for bar path builders; reports bar rects while {@link measureBar} runs. */
// eslint-disable-next-line max-params
export function reportBarRect(
_u: uPlot,
_seriesIdx: number,
_idx: number,
left: number,
top: number,
width: number,
height: number,
): void {
onBarRect?.({ left, top, width, height });
}
/**
* Canvas rect of one bar, from re-running the series' paths for that index.
* `null` for series not drawn as bars.
*/
export function measureBar(
u: uPlot,
seriesIdx: number,
idx: number,
): BarRect | null {
let rect: BarRect | null = null;
onBarRect = (measured): void => {
rect = measured;
};
try {
u.series[seriesIdx].paths?.(u, seriesIdx, idx, idx);
} finally {
onBarRect = null;
}
return rect;
}

View File

@@ -51,3 +51,38 @@ export const opsGeniePriorityDefaultValue =
export const pagerDutySeverityTextDefaultValue =
'{{ (index .Alerts 0).Labels.severity }}';
export const notificationChannelsV2 = [
{
id: '3',
name: 'dummy-channel',
displayName: 'Dummy-Channel',
kind: 'slack',
createdAt: '2023-08-09T04:45:19.239344617Z',
updatedAt: '2024-06-27T11:37:14.841184399Z',
},
{
id: '4',
name: 'oncall-pagerduty',
displayName: 'Oncall PagerDuty',
kind: 'pagerduty',
createdAt: '2024-02-03T04:45:19.239344617Z',
updatedAt: '2024-06-28T11:37:14.841184399Z',
},
];
export const notificationChannelV2 = {
id: '3',
name: 'dummy-channel',
displayName: 'Dummy-Channel',
createdAt: '2023-08-09T04:45:19.239344617Z',
updatedAt: '2024-06-27T11:37:14.841184399Z',
config: {
kind: 'slack',
spec: {
apiUrl: 'https://hooks.slack.com/services/dummy',
channel: '#dummy_channel',
sendResolved: true,
},
},
};

View File

@@ -2,7 +2,11 @@ import { rest } from 'msw';
import commonEnTranslation from '../../public/locales/en/common.json';
import enTranslation from '../../public/locales/en/translation.json';
import { allAlertChannels } from './__mockdata__/alerts';
import {
allAlertChannels,
notificationChannelsV2,
notificationChannelV2,
} from './__mockdata__/alerts';
import { alertRulesFixture } from './__mockdata__/alert_rules';
import { triggeredAlertsFixture } from './__mockdata__/triggered_alerts';
import { billingSuccessResponse } from './__mockdata__/billing';
@@ -205,6 +209,32 @@ export const handlers = [
rest.get('http://localhost/api/v1/channels', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: allAlertChannels, status: 'success' })),
),
rest.get('http://localhost/api/v2/notification_channels', (req, res, ctx) => {
const query = req.url.searchParams.get('query')?.toLowerCase() ?? '';
const kind = req.url.searchParams.get('kind');
const channels = notificationChannelsV2.filter(
(channel) =>
(!query || channel.displayName.toLowerCase().includes(query)) &&
(!kind || channel.kind === kind),
);
return res(
ctx.status(200),
ctx.json({
data: { channels, total: channels.length },
status: 'success',
}),
);
}),
rest.get('http://localhost/api/v2/notification_channels/:id', (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: notificationChannelV2, status: 'success' }),
),
),
rest.delete(
'http://localhost/api/v2/notification_channels/:id',
(_, res, ctx) => res(ctx.status(204)),
),
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
res(
ctx.status(200),

View File

@@ -24,7 +24,7 @@ import {
import {
alertRuleByIdResponse,
ALERT_SCHEMAS,
channelsResponse,
notificationChannelsResponse,
CHANNEL_MAX,
FIRST_RULE_NAME,
type AlertSchema,
@@ -148,8 +148,8 @@ export const alertHistoryMocks = defineStoryMocks({
),
rest.get(
'http://localhost/api/v1/channels',
response.json(() => channelsResponse(CHANNEL_MAX)),
'http://localhost/api/v2/notification_channels',
response.json(() => notificationChannelsResponse(CHANNEL_MAX)),
),
],
config: () => ({

View File

@@ -12,7 +12,7 @@ import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
alertRuleByIdResponse,
ALERT_SCHEMAS,
channelsResponse,
notificationChannelsResponse,
CHANNEL_MAX,
RULE_STATE_CHOICES,
SEVERITY_CHOICES,
@@ -116,8 +116,8 @@ export const alertOverviewMocks = defineStoryMocks({
),
rest.get(
'http://localhost/api/v1/channels',
response.json(() => channelsResponse(values.channels)),
'http://localhost/api/v2/notification_channels',
response.json(() => notificationChannelsResponse(values.channels)),
),
rest.post(

View File

@@ -10,7 +10,7 @@ import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
CHANNEL_MAX,
channelsResponse,
notificationChannelsResponse,
} from '../../stories/__story_mockdata__/alerts';
import { AlertListTabs } from '../../types';
@@ -27,8 +27,8 @@ export const channelsMocks = defineStoryMocks({
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v1/channels',
response.json(() => channelsResponse(values.channels)),
'http://localhost/api/v2/notification_channels',
response.json(() => notificationChannelsResponse(values.channels)),
),
],
config: () => ({ route: `/alerts?tab=${AlertListTabs.CHANNELS}` }),

View File

@@ -0,0 +1,144 @@
.container {
--control-height: 2rem;
display: flex;
flex-direction: column;
height: calc(100vh - 62px);
min-height: 400px;
--combobox-trigger-height: var(--control-height);
--select-trigger-height: var(--control-height);
--input-height: var(--control-height);
--input-wrapper-height: var(--control-height);
--button-height: var(--control-height);
}
/* Title, count and filters sit on the page surface, with matching space above
and below before the table starts. */
.headerBand {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
flex-shrink: 0;
padding: var(--spacing-6) var(--spacing-8);
}
/* Title and count on the left, the create action at the far end, the way the
dashboards list opens. */
.commandHeader {
display: flex;
align-items: center;
gap: 12px;
}
.headingBlock {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.title {
margin: 0;
color: var(--l1-foreground);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-medium);
line-height: 28px;
letter-spacing: -0.09px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.countPill {
padding: 2px 9px;
border-radius: 999px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
color: var(--l2-foreground);
font-size: var(--font-size-xs);
font-variant-numeric: tabular-nums;
}
.grow {
flex: 1;
}
/* Search and the type filter, the band's second row. */
.filtersRow {
position: relative;
z-index: 10;
display: flex;
align-items: center;
gap: var(--spacing-4);
flex-wrap: wrap;
}
/* Spans the row, the way the dashboards filter bar does. The input's own
wrapper is `width: 100%`, so the basis has to be 0 or `auto` resolves to the
full row and wraps the type filter onto the next line. */
.searchInput {
flex: 1 1 0;
min-width: 220px;
}
.kindSelect {
/* the trigger sizes itself to 100%, so the flex item has to be bounded */
flex: 0 0 180px;
width: 180px;
max-width: 180px;
}
/* The options panel is rendered inline (withPortal={false}) so it can be told to
match the trigger; Radix publishes that width at the popper layer. */
.filtersRow :global([data-slot='select-content']) {
width: var(--radix-select-trigger-width);
min-width: var(--radix-select-trigger-width);
}
.commandHeader > button {
flex: 0 0 auto;
}
.searchIcon {
color: var(--l2-foreground);
}
.actionsHeader {
display: block;
text-align: right;
}
/* Left on the table's own defaults, the way Infrastructure Monitoring uses it:
transparent cells over the page background rather than a raised slab. */
.tableContainer {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
--tanstack-table-row-hover-bg: var(--l2-background-hover);
--tanstack-table-row-active-bg: var(--l2-background-active);
--tanstack-table-row-height: 42px;
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-left-override: 16px;
--tanstack-cell-padding-right-override: 16px;
--tanstack-table-row-odd-bg: color-mix(
in srgb,
var(--l1-foreground) 2%,
transparent
);
--tanstack-table-row-even-bg: color-mix(
in srgb,
var(--l1-foreground) 1%,
transparent
);
}
.paginationContainer {
padding-right: var(--spacing-12);
height: 62px;
}

View File

@@ -0,0 +1,253 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Plus, Search } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
import TanStackTable from 'components/TanStackTableView';
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
import { useTableParams } from 'components/TanStackTableView/useTableParams';
import TextToolTip from 'components/TextToolTip';
import { useUrlSearchState } from 'hooks/useUrlSearchState';
import { parseAsString, useQueryState } from 'nuqs';
import { useTimezone } from 'providers/Timezone';
import ChannelActionsMenu from './components/ChannelActionsMenu/ChannelActionsMenu';
import ChannelJsonDialog from './components/ChannelJsonDialog/ChannelJsonDialog';
import {
KIND_FILTER_ALL,
KIND_FILTER_OPTIONS,
KIND_KEY,
SEARCH_KEY,
} from './constants';
import { useChannelDelete } from './hooks/useChannelDelete';
import { useChannelFormView } from './hooks/useChannelFormView';
import { useChannelsData } from './hooks/useChannelsData';
import styles from './NotificationChannels.module.scss';
import { getChannelColumns } from './table.config';
import type { Channel } from './types';
const QUERY_PARAMS_CONFIG = {
orderBy: 'orderBy',
page: 'page',
limit: 'limit',
} as const;
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 10;
function NotificationChannels(): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const formView = useChannelFormView();
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
rowHeight: 46,
});
const { orderBy, page, limit, setLimit, setPage } = useTableParams(
QUERY_PARAMS_CONFIG,
{
page: DEFAULT_PAGE,
limit: DEFAULT_LIMIT,
storageKey: 'notification-channels',
calculatedPageSize,
cleanupOnUnmount: true,
},
);
const resetPageOnSearch = useCallback((): void => {
setPage(1);
}, [setPage]);
const { searchText, debouncedSearch, handleSearchChange, clearSearch } =
useUrlSearchState(SEARCH_KEY, { onDebouncedChange: resetPageOnSearch });
const [kind, setKind] = useQueryState(
KIND_KEY,
parseAsString.withDefault(KIND_FILTER_ALL),
);
const handleKindChange = useCallback(
(value: string | string[]): void => {
const next = value as string;
void setKind(next === KIND_FILTER_ALL ? null : next);
setPage(1);
},
[setKind, setPage],
);
const { channels, total, isFetching, isError, refetch } = useChannelsData({
search: debouncedSearch,
kind,
orderBy,
page,
limit,
});
const deletion = useChannelDelete();
const [jsonChannel, setJsonChannel] = useState<Channel | null>(null);
useEffect(() => {
void logEvent('Alert Channel: Channel list page visited', {});
}, []);
const handleEdit = useCallback(
(channel: Channel, options?: { newTab?: boolean }): void => {
formView.openEdit(channel.id, options);
},
[formView],
);
const handleRowClick = useCallback(
(channel: Channel): void => formView.openEdit(channel.id),
[formView],
);
const handleRowClickNewTab = useCallback(
(channel: Channel): void => formView.openEdit(channel.id, { newTab: true }),
[formView],
);
const columns = useMemo(
() => getChannelColumns(formatTimezoneAdjustedTimestamp),
[formatTimezoneAdjustedTimestamp],
);
const columnsWithActions = useMemo(
() => [
...columns,
{
id: 'actions',
header: (): JSX.Element => (
<span className={styles.actionsHeader}>Actions</span>
),
accessorKey: 'id',
width: { fixed: '96px', ignoreLastColumnFill: true },
enableSort: false,
enableResize: false,
enableRemove: false,
enableMove: false,
pin: 'right' as const,
cell: ({ row }: { row: Channel }): JSX.Element => (
<ChannelActionsMenu
channel={row}
onOpen={handleEdit}
onDelete={deletion.request}
onViewJson={setJsonChannel}
/>
),
},
],
[columns, handleEdit, deletion.request],
);
const hasActiveFilters = searchText.length > 0 || kind !== KIND_FILTER_ALL;
const isEmpty = !isFetching && channels.length === 0;
const handleClearFilters = useCallback((): void => {
clearSearch();
void setKind(null);
}, [clearSearch, setKind]);
return (
<div className={styles.container}>
<header className={styles.headerBand}>
<div className={styles.commandHeader}>
<div className={styles.headingBlock}>
<Typography.Title className={styles.title}>
All notification channels
</Typography.Title>
<span className={styles.countPill}>{total}</span>
</div>
<div className={styles.grow} />
<TextToolTip
text="More details on how to configure notification channels"
url="https://signoz.io/docs/setup-alerts-notification/"
urlText="Learn More"
/>
<Button
variant="solid"
color="primary"
size="sm"
prefix={<Plus size={14} />}
onClick={formView.openCreate}
testId="channels-create"
>
New channel
</Button>
</div>
<div className={styles.filtersRow}>
<Input
containerClassName={styles.searchInput}
placeholder="Search by channel name"
value={searchText}
onChange={handleSearchChange}
suffix={<Search size={14} className={styles.searchIcon} />}
testId="channels-search"
/>
<SelectSimple
className={styles.kindSelect}
items={KIND_FILTER_OPTIONS}
value={kind}
onChange={handleKindChange}
withPortal={false}
testId="channels-kind-filter"
/>
</div>
</header>
<div ref={containerRef} className={styles.tableContainer}>
{isError ? (
<ErrorEmptyState
title="Failed to load notification channels"
onRefresh={refetch}
/>
) : isEmpty && hasActiveFilters ? (
<NoResultsEmptyState
title="No matching channels"
subtitle="No notification channels match your search. Try adjusting your search criteria."
onClear={handleClearFilters}
clearButtonText="Clear Search"
/>
) : (
<TanStackTable<Channel>
data={channels}
columns={columnsWithActions}
isLoading={isFetching}
getRowKey={(row): string => row.id}
getItemKey={(row): string => row.id}
columnStorageKey="notification-channels-columns"
enableQueryParams={QUERY_PARAMS_CONFIG}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
pagination={{
total,
calculatedPageSize,
onLimitChange: setLimit,
showTotalCount: true,
}}
paginationClassname={styles.paginationContainer}
enableAlternatingRowColors
plainTextCellLineClamp={1}
/>
)}
</div>
{jsonChannel && (
<ChannelJsonDialog
channelId={jsonChannel.id}
channelName={jsonChannel.displayName}
onClose={(): void => setJsonChannel(null)}
/>
)}
{deletion.contextHolder}
</div>
);
}
export default NotificationChannels;

View File

@@ -0,0 +1,100 @@
import { server } from 'mocks-server/server';
import { screen, userEvent, waitFor } from 'tests/test-utils';
import { renderChannels } from './renderChannels';
const FIND = { timeout: 5000 };
const TEST_TIMEOUT = 20000;
describe('Notification channels list', () => {
beforeEach(() => {
// search, filter and page live in the url, so one test's filter would
// otherwise still be applied in the next
window.history.replaceState({}, '', '/');
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it(
'heads the page with the channel count',
async () => {
renderChannels();
await expect(
screen.findByText('All notification channels', {}, FIND),
).resolves.toBeInTheDocument();
// the count comes from the API's total, not the loaded page
await expect(screen.findByText('2', {}, FIND)).resolves.toBeInTheDocument();
},
TEST_TIMEOUT,
);
it(
'lists every channel with its type',
async () => {
renderChannels();
await expect(
screen.findByText('Dummy-Channel', {}, FIND),
).resolves.toBeInTheDocument();
expect(screen.getByText('Oncall PagerDuty')).toBeInTheDocument();
// the type reads as plain text, no colour coding
expect(screen.getByText('Slack')).toBeInTheDocument();
expect(screen.getByText('PagerDuty')).toBeInTheDocument();
},
TEST_TIMEOUT,
);
it(
'filters server-side as the user types',
async () => {
renderChannels();
await screen.findByText('Dummy-Channel', {}, FIND);
await userEvent.type(screen.getByTestId('channels-search'), 'oncall');
await waitFor(() => {
expect(screen.queryByText('Dummy-Channel')).not.toBeInTheDocument();
}, FIND);
await expect(
screen.findByText('Oncall PagerDuty', {}, FIND),
).resolves.toBeInTheDocument();
},
TEST_TIMEOUT,
);
it(
'asks for confirmation before deleting, from the row menu',
async () => {
renderChannels();
await screen.findByText('Dummy-Channel', {}, FIND);
// the table paints a loading layer over the rows until the fetch settles
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.click(screen.getByTestId('channel-actions-3'));
// Radix renders the menu into a portal once the trigger is activated
await screen.findByRole('menu', {}, FIND);
// the menu lives inside the row's clickable cell, so opening it must not
// also fire the row click and route to the edit screen
expect(window.location.pathname).not.toContain('/channels/edit/');
await user.click(
await screen.findByRole('menuitem', { name: 'Delete' }, FIND),
);
// the shared destructive confirmation, the one dashboards also use
await expect(
screen.findByText(
/Are you sure you want to delete the Dummy-Channel channel\?/,
{},
FIND,
),
).resolves.toBeInTheDocument();
},
TEST_TIMEOUT,
);
});

View File

@@ -0,0 +1,54 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import {
channelToJson,
hasSecrets,
SECRET_PLACEHOLDER,
} from '../utils/channelJson';
const slackChannel = {
id: '1',
name: 'prod-alerts',
displayName: 'prod alerts',
createdAt: '2026-09-01T00:00:00Z',
updatedAt: '2026-09-01T00:00:00Z',
config: {
kind: 'slack',
spec: {
apiUrl: 'https://hooks.slack.com/services/T/B/SECRET',
channel: '#alerts',
sendResolved: true,
},
},
} as AlertmanagertypesGettableNotificationChannelDTO;
describe('channelToJson', () => {
it('hides the credential the API returns in the clear', () => {
const json = JSON.parse(channelToJson(slackChannel, false));
expect(json.config.spec.apiUrl).toBe(SECRET_PLACEHOLDER);
expect(json.config.spec.channel).toBe('#alerts');
expect(channelToJson(slackChannel, false)).not.toContain('SECRET');
});
it('reveals it only when asked', () => {
const json = JSON.parse(channelToJson(slackChannel, true));
expect(json.config.spec.apiUrl).toBe(
'https://hooks.slack.com/services/T/B/SECRET',
);
});
it('leaves the timestamps and id out, so the JSON can be reused', () => {
const json = JSON.parse(channelToJson(slackChannel, true));
expect(json).not.toHaveProperty('id');
expect(json).not.toHaveProperty('createdAt');
expect(json.name).toBe('prod-alerts');
});
it('knows which kinds carry a credential', () => {
expect(hasSecrets('slack')).toBe(true);
expect(hasSecrets('email')).toBe(false);
});
});

View File

@@ -0,0 +1,13 @@
import { VirtuosoMockContext } from 'react-virtuoso';
import { render } from 'tests/test-utils';
import NotificationChannels from '../NotificationChannels';
/** The list is virtualized, so jsdom needs a viewport for rows to mount. */
export function renderChannels(): ReturnType<typeof render> {
return render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 800, itemHeight: 46 }}>
<NotificationChannels />
</VirtuosoMockContext.Provider>,
);
}

View File

@@ -0,0 +1,15 @@
.actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 6px;
}
.actions button {
opacity: 0.7;
transition: opacity 0.15s ease;
&:hover {
opacity: 1;
}
}

View File

@@ -0,0 +1,87 @@
import { useMemo } from 'react';
import {
Braces,
Ellipsis,
PenLine,
SquareArrowOutUpRight,
Trash2,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import type { Channel } from '../../types';
import styles from './ChannelActionsMenu.module.scss';
interface ChannelActionsMenuProps {
channel: Channel;
onOpen: (channel: Channel, options?: { newTab?: boolean }) => void;
onDelete: (channel: Channel) => void;
onViewJson: (channel: Channel) => void;
}
function ChannelActionsMenu({
channel,
onOpen,
onDelete,
onViewJson,
}: ChannelActionsMenuProps): JSX.Element {
const menuItems = useMemo<DropdownItemType[]>(
() => [
{
type: 'item',
value: 'open',
label: 'Edit',
prefix: <PenLine size={14} />,
onClick: (): void => onOpen(channel),
},
{
type: 'item',
value: 'open-new-tab',
label: 'Open in New Tab',
prefix: <SquareArrowOutUpRight size={14} />,
onClick: (): void => onOpen(channel, { newTab: true }),
},
{
type: 'item',
value: 'view-json',
label: 'View JSON',
prefix: <Braces size={14} />,
onClick: (): void => onViewJson(channel),
},
{ type: 'separator', value: 'before-delete' },
{
type: 'item',
value: 'delete',
label: 'Delete',
prefix: <Trash2 size={14} />,
danger: true,
onClick: (): void => onDelete(channel),
},
],
[channel, onOpen, onDelete, onViewJson],
);
return (
// the row itself opens the channel, so the actions keep their clicks
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className={styles.actions}
onClick={(event): void => event.stopPropagation()}
>
<Dropdown items={menuItems} nativeButton align="end" side="bottom">
<Button
variant="outlined"
color="secondary"
size="sm"
icon
testId={`channel-actions-${channel.id}`}
aria-label={`Actions for ${channel.displayName}`}
>
<Ellipsis size={16} />
</Button>
</Dropdown>
</div>
);
}
export default ChannelActionsMenu;

View File

@@ -0,0 +1,26 @@
.body {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
height: 100%;
min-height: 420px;
}
.toolbar {
display: flex;
align-items: center;
gap: var(--spacing-2);
flex-wrap: wrap;
}
.spacer {
flex: 1 1 auto;
}
.editor {
flex: 1 1 auto;
min-height: 360px;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
overflow: hidden;
}

View File

@@ -0,0 +1,116 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import MEditor from '@monaco-editor/react';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { useGetNotificationChannel } from 'api/generated/services/channels';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import Spinner from 'components/Spinner';
import { READONLY_EDITOR_OPTIONS } from 'container/RolesSettings/monaco.config';
import {
defineJsonEditorTheme,
JSON_EDITOR_THEME,
} from 'lib/monaco/jsonEditorTheme';
import { channelToJson, hasSecrets } from '../../utils/channelJson';
import styles from './ChannelJsonDialog.module.scss';
import ChannelJsonToolbar from './ChannelJsonToolbar';
interface ChannelJsonDialogProps {
channelId: string;
channelName: string;
onClose: () => void;
}
function ChannelJsonDialog({
channelId,
channelName,
onClose,
}: ChannelJsonDialogProps): JSX.Element {
const [copyState, copyToClipboard] = useCopyToClipboard();
const [copied, setCopied] = useState(false);
const [showSecrets, setShowSecrets] = useState(false);
const { data, isLoading, refetch } = useGetNotificationChannel({
id: channelId,
});
const channel = data?.data;
const json = useMemo(
() => (channel ? channelToJson(channel, showSecrets) : ''),
[channel, showSecrets],
);
useEffect(() => {
if (copyState.value) {
setCopied(true);
const timer = setTimeout(() => setCopied(false), 1500);
return (): void => clearTimeout(timer);
}
return undefined;
}, [copyState]);
const handleCopy = useCallback(
(): void => copyToClipboard(json),
[copyToClipboard, json],
);
const handleRefetch = useCallback((): void => {
void refetch();
}, [refetch]);
const handleToggleSecrets = useCallback(
(): void => setShowSecrets((value) => !value),
[],
);
const kindHasSecrets = channel ? hasSecrets(channel.config.kind) : false;
return (
<DrawerWrapper
open
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={channelName}
subTitle="Channel JSON"
width="wide"
testId="channel-json-dialog"
>
{isLoading ? (
<Spinner tip="Loading channel..." />
) : !channel ? (
<ErrorEmptyState title="Failed to load channel" onRefresh={handleRefetch} />
) : (
<div className={styles.body}>
<ChannelJsonToolbar
kindHasSecrets={kindHasSecrets}
showSecrets={showSecrets}
onToggleSecrets={handleToggleSecrets}
copied={copied}
onCopy={handleCopy}
/>
<div className={styles.editor}>
<MEditor
value={json}
language="json"
options={READONLY_EDITOR_OPTIONS}
height="100%"
theme="vs-dark"
// the same theme the dashboard JSON drawer uses: it reads the
// page's own tokens, so it follows light and dark
onMount={(editor, monaco): void => {
defineJsonEditorTheme(monaco, editor.getContainerDomNode());
monaco.editor.setTheme(JSON_EDITOR_THEME);
}}
/>
</div>
</div>
)}
</DrawerWrapper>
);
}
export default ChannelJsonDialog;

View File

@@ -0,0 +1,63 @@
import { Color } from '@signozhq/design-tokens';
import { Check, Copy, Eye, EyeOff } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './ChannelJsonDialog.module.scss';
interface ChannelJsonToolbarProps {
kindHasSecrets: boolean;
showSecrets: boolean;
onToggleSecrets: () => void;
copied: boolean;
onCopy: () => void;
}
function ChannelJsonToolbar({
kindHasSecrets,
showSecrets,
onToggleSecrets,
copied,
onCopy,
}: ChannelJsonToolbarProps): JSX.Element {
return (
<div className={styles.toolbar}>
{kindHasSecrets && (
<Typography.Text color="muted" size="sm">
{showSecrets ? 'Credentials are visible.' : 'Credentials are hidden.'}
</Typography.Text>
)}
<span className={styles.spacer} />
{kindHasSecrets && (
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={showSecrets ? <EyeOff size={14} /> : <Eye size={14} />}
onClick={onToggleSecrets}
testId="channel-json-toggle-secrets"
>
{showSecrets ? 'Hide credentials' : 'Show credentials'}
</Button>
)}
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={
copied ? (
<Check size={14} color={Color.BG_FOREST_400} />
) : (
<Copy size={14} />
)
}
onClick={onCopy}
testId="channel-json-copy"
>
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
);
}
export default ChannelJsonToolbar;

View File

@@ -0,0 +1,34 @@
import { AlertmanagertypesChannelKindDTO } from 'api/generated/services/sigNoz.schemas';
export const SEARCH_KEY = 'search';
export const KIND_KEY = 'kind';
export const PAGE_KEY = 'page';
export const PAGE_SIZE = 20;
export const SEARCH_DEBOUNCE_MS = 300;
export const CHANNEL_KIND_LABEL: Record<
AlertmanagertypesChannelKindDTO,
string
> = {
[AlertmanagertypesChannelKindDTO.slack]: 'Slack',
[AlertmanagertypesChannelKindDTO.email]: 'Email',
[AlertmanagertypesChannelKindDTO.webhook]: 'Webhook',
[AlertmanagertypesChannelKindDTO.pagerduty]: 'PagerDuty',
[AlertmanagertypesChannelKindDTO.opsgenie]: 'Opsgenie',
[AlertmanagertypesChannelKindDTO.msteams]: 'MS Teams',
[AlertmanagertypesChannelKindDTO.googlechat]: 'Google Chat',
[AlertmanagertypesChannelKindDTO.jira]: 'Jira',
[AlertmanagertypesChannelKindDTO.jsmops]: 'JSM Ops',
[AlertmanagertypesChannelKindDTO.incidentio]: 'incident.io',
};
export const KIND_FILTER_ALL = 'all';
export const KIND_FILTER_OPTIONS = [
{ value: KIND_FILTER_ALL, label: 'All types' },
...Object.entries(CHANNEL_KIND_LABEL).map(([value, label]) => ({
value,
label,
})),
];

View File

@@ -0,0 +1,55 @@
import { useQueryClient } from 'react-query';
import { Typography } from '@signozhq/ui/typography';
import {
invalidateListNotificationChannels,
useDeleteNotificationChannel,
} from 'api/generated/services/channels';
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { ReactNode } from 'react';
import APIError from 'types/api/error';
import type { Channel } from '../types';
export interface ChannelDeleteState {
/** Must be rendered by the caller for the confirmation to appear. */
contextHolder: ReactNode;
request: (channel: Channel) => void;
}
/**
* Deleting a channel goes through the shared destructive confirmation, the same
* one dashboards and saved views use, so the wording and the danger button match.
*/
export function useChannelDelete(onDeleted?: () => void): ChannelDeleteState {
const queryClient = useQueryClient();
const { showErrorModal } = useErrorModal();
const { contextHolder, confirmDelete } = useDeleteConfirm();
const { mutateAsync } = useDeleteNotificationChannel();
const request = (channel: Channel): void => {
confirmDelete({
title: (
<Typography.Title level={5}>
Are you sure you want to delete the {channel.displayName} channel?
</Typography.Title>
),
// Alert rules and routing policies reference a channel by name, so a
// delete can silence them.
content:
'Alert rules and routing policies that notify this channel will stop reaching it.',
// Returning the promise keeps Delete loading until the request settles.
onConfirm: async (): Promise<void> => {
try {
await mutateAsync({ pathParams: { id: channel.id } });
await invalidateListNotificationChannels(queryClient);
onDeleted?.();
} catch (error) {
showErrorModal(error as APIError);
}
},
});
};
return { contextHolder, request };
}

View File

@@ -0,0 +1,28 @@
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { openInNewTab } from 'utils/navigation';
export interface ChannelFormView {
openCreate: () => void;
openEdit: (channelId: string, options?: { newTab?: boolean }) => void;
}
/** Create and edit both render on their own route. */
export function useChannelFormView(): ChannelFormView {
const openEdit = (channelId: string, options?: { newTab?: boolean }): void => {
const path = generatePath(ROUTES.CHANNELS_EDIT, { channelId });
if (options?.newTab) {
openInNewTab(path);
return;
}
history.push(path);
};
return {
openCreate: (): void => history.push(ROUTES.CHANNELS_NEW),
openEdit,
};
}

View File

@@ -0,0 +1,75 @@
import { useListNotificationChannels } from 'api/generated/services/channels';
import {
AlertmanagertypesChannelKindDTO,
AlertmanagertypesChannelListOrderDTO,
AlertmanagertypesChannelListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SortState } from 'components/TanStackTableView';
import { KIND_FILTER_ALL } from '../constants';
import type { Channel } from '../types';
/** Table column ids the list API can order by. */
const SORT_BY_COLUMN: Record<string, AlertmanagertypesChannelListSortDTO> = {
name: AlertmanagertypesChannelListSortDTO.name,
createdAt: AlertmanagertypesChannelListSortDTO.created_at,
updatedAt: AlertmanagertypesChannelListSortDTO.updated_at,
};
interface UseChannelsDataArgs {
search: string;
kind: string;
orderBy: SortState | null;
page: number;
limit: number;
enabled?: boolean;
}
export interface ChannelsData {
channels: Channel[];
total: number;
isFetching: boolean;
isError: boolean;
refetch: () => void;
}
/**
* Search, kind filter, sort and paging all run on the API, so the table shows
* one page at a time rather than filtering a full download in the browser.
*/
export function useChannelsData({
search,
kind,
orderBy,
page,
limit,
enabled = true,
}: UseChannelsDataArgs): ChannelsData {
const sort = orderBy ? SORT_BY_COLUMN[orderBy.columnName] : undefined;
const { data, isFetching, isError, refetch } = useListNotificationChannels(
{
limit,
offset: (page - 1) * limit,
...(sort
? {
sort,
order: orderBy?.order as AlertmanagertypesChannelListOrderDTO,
}
: {}),
...(search ? { query: search } : {}),
...(kind !== KIND_FILTER_ALL
? { kind: kind as AlertmanagertypesChannelKindDTO }
: {}),
},
{ query: { enabled, keepPreviousData: true } },
);
return {
channels: data?.data?.channels ?? [],
total: data?.data?.total ?? 0,
isFetching,
isError,
refetch,
};
}

View File

@@ -0,0 +1,88 @@
import type { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { CHANNEL_KIND_LABEL } from './constants';
import styles from './table.module.scss';
import type { Channel } from './types';
import { toRelativeTime } from './utils/relativeTime';
export function getChannelColumns(
formatTimezoneAdjustedTimestamp: (date: string, format: string) => string,
): TableColumnDef<Channel>[] {
const timestampCell = (row: Channel, value: string): JSX.Element => {
if (!value) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<TanStackTable.HoverTooltip
rowId={row.id}
title={formatTimezoneAdjustedTimestamp(
value,
DATE_TIME_FORMATS.DASH_DATETIME,
)}
>
{/* A plain inline-block hugs the text. TanStackTable.Text is a
`-webkit-box` for line clamping, which fills the cell and leaves
the arrow pointing at the cell's middle instead of the words. */}
<span className={styles.timeText}>{toRelativeTime(value)}</span>
</TanStackTable.HoverTooltip>
);
};
return [
{
id: 'name',
header: 'Channel',
accessorKey: 'displayName',
width: { default: '100%', min: '220px' },
enableSort: true,
enableResize: true,
enableRemove: false,
enableMove: false,
cell: ({ row }): JSX.Element => (
<TanStackTable.Text
title={row.displayName}
data-testid={`channel-row-${row.id}-name`}
>
{row.displayName}
</TanStackTable.Text>
),
},
{
id: 'kind',
header: 'Type',
accessorKey: 'kind',
width: { default: '150px', min: '110px' },
enableSort: false,
enableResize: true,
enableMove: false,
cell: ({ row }): JSX.Element => (
<TanStackTable.Text data-testid={`channel-row-${row.id}-kind`}>
{CHANNEL_KIND_LABEL[row.kind]}
</TanStackTable.Text>
),
},
{
id: 'createdAt',
header: 'Created At',
accessorKey: 'createdAt',
width: { default: '150px', min: '120px' },
enableSort: true,
enableResize: true,
enableMove: false,
cell: ({ row }): JSX.Element => timestampCell(row, row.createdAt),
},
{
id: 'updatedAt',
header: 'Updated At',
accessorKey: 'updatedAt',
width: { default: '150px', min: '120px' },
enableSort: true,
enableResize: true,
enableMove: false,
cell: ({ row }): JSX.Element => timestampCell(row, row.updatedAt),
},
];
}

View File

@@ -0,0 +1,7 @@
.timeText {
display: inline-block;
white-space: nowrap;
font-size: var(--tanstack-plain-cell-font-size, 14px);
line-height: var(--tanstack-plain-cell-line-height, 18px);
color: inherit;
}

View File

@@ -0,0 +1,4 @@
import { AlertmanagertypesListedNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
/** A channel as the list API returns it: identity and kind, but no config. */
export type Channel = AlertmanagertypesListedNotificationChannelDTO;

View File

@@ -0,0 +1,50 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
export const SECRET_PLACEHOLDER = '••••••••';
/**
* The spec fields the API marks `format: password`. A fetch by ID returns them
* in the clear, so the JSON view hides them until asked.
*/
const SECRET_FIELDS_BY_KIND: Record<string, readonly string[]> = {
slack: ['apiUrl'],
webhook: ['url', 'password', 'bearerToken'],
pagerduty: ['routingKey'],
opsgenie: ['apiKey'],
msteams: ['webhookUrl'],
googlechat: ['webhookUrl'],
jira: ['apiToken'],
jsmops: ['apiKey'],
incidentio: ['token'],
};
export function channelToJson(
channel: AlertmanagertypesGettableNotificationChannelDTO,
showSecrets: boolean,
): string {
const { kind, spec } = channel.config;
const secrets = SECRET_FIELDS_BY_KIND[kind] ?? [];
const safeSpec = showSecrets
? spec
: Object.fromEntries(
Object.entries(spec).map(([key, value]) => [
key,
secrets.includes(key) && value ? SECRET_PLACEHOLDER : value,
]),
);
return JSON.stringify(
{
name: channel.name,
displayName: channel.displayName,
config: { kind, spec: safeSpec },
},
null,
2,
);
}
export function hasSecrets(kind: string): boolean {
return (SECRET_FIELDS_BY_KIND[kind] ?? []).length > 0;
}

View File

@@ -0,0 +1,10 @@
import dayjs from 'dayjs';
import relativeTimePlugin from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTimePlugin);
/** "2 months ago". The exact timestamp stays available on hover. */
export function toRelativeTime(value: string | undefined): string {
const parsed = value ? dayjs(value) : null;
return parsed?.isValid() ? parsed.fromNow() : '-';
}

View File

@@ -17,7 +17,7 @@ import {
import {
CHANNEL_MAX,
channelNames,
channelsResponse,
notificationChannelsResponse,
} from '../../stories/__story_mockdata__/alerts';
import { AlertListSubTabs, AlertListTabs } from '../../types';
@@ -118,10 +118,10 @@ export const routingPoliciesMocks = defineStoryMocks({
: res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
rest.get('http://localhost/api/v1/channels', (_req, res, ctx) =>
rest.get('http://localhost/api/v2/notification_channels', (_req, res, ctx) =>
values.channelsState === 'error'
? res(ctx.status(500), ctx.json({ status: 'error' }))
: res(ctx.json(channelsResponse(values.channels))),
: res(ctx.json(notificationChannelsResponse(values.channels))),
),
],
config: () => ({

View File

@@ -4,7 +4,6 @@ import { Tabs, TabsProps } from 'antd';
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import ROUTES from 'constants/routes';
import AllAlertChannels from 'container/AllAlertChannels';
import AllAlertRules from 'container/ListAlertRules';
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
import RoutingPolicies from 'container/RoutingPolicies';
@@ -16,6 +15,8 @@ import AlertDetails from 'pages/AlertDetails';
import ChannelsEdit from 'pages/ChannelsEdit';
import ChannelsNew from 'pages/ChannelsNew';
import NotificationChannels from './NotificationChannels/NotificationChannels';
import { AlertListSubTabs, AlertListTabs } from './types';
import './AlertList.styles.scss';
@@ -104,7 +105,7 @@ function AllAlertList(): JSX.Element {
<div className="alert-rules-container">
{isChannelsNew && <ChannelsNew />}
{isChannelsEdit && <ChannelsEdit />}
{!isChannelDetails && <AllAlertChannels />}
{!isChannelDetails && <NotificationChannels />}
</div>
),
},

View File

@@ -26,6 +26,9 @@ import {
type RuletypesAlertCompositeQueryDTO,
type RuletypesRuleConditionDTO,
type RuletypesRuleDTO,
type AlertmanagertypesChannelConfigDTO,
type AlertmanagertypesGettableNotificationChannelDTO,
type AlertmanagertypesListedNotificationChannelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
import type { Channels } from 'types/api/channels/getAll';
@@ -704,6 +707,22 @@ const buildChannel = (index: number): Channels => {
};
};
const buildListedChannel = (
index: number,
): AlertmanagertypesListedNotificationChannelDTO => {
const seed = CHANNEL_SEEDS[index % CHANNEL_SEEDS.length];
return {
id: String(index + 1),
name: seed.name,
displayName: seed.name,
kind: seed.type as AlertmanagertypesListedNotificationChannelDTO['kind'],
createdAt: ago((index + 10) * DAY),
updatedAt: ago((index + 1) * DAY),
};
};
/** The v2 list, which is what the channel pickers read. */
export const channelsResponse = (
count: number,
): { status: string; data: Channels[] } => ({
@@ -711,10 +730,97 @@ export const channelsResponse = (
data: Array.from({ length: count }, (_unused, index) => buildChannel(index)),
});
/** The v2 list, which is what the redesigned channels list reads. */
export const notificationChannelsResponse = (
count: number,
): {
status: string;
data: {
channels: AlertmanagertypesListedNotificationChannelDTO[];
total: number;
};
} => {
const channels = Array.from({ length: count }, (_unused, index) =>
buildListedChannel(index),
);
return { status: 'success', data: { channels, total: channels.length } };
};
/** Channel names the alert form and the routing policies pick from. */
export const channelNames = (count: number): string[] =>
channelsResponse(count).data.map((channel) => channel.name);
/**
* The v2 spec each kind is fetched with, so the edit form renders populated
* fields. Keys mirror the generated channel spec schemas.
*/
const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
slack: {
apiUrl: 'https://hooks.slack.com/services/T000/B000/story-token',
channel: '#ops-alerts',
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}',
text: '{{ range .Alerts -}}*Alert:* {{ .Labels.alertname }}\n{{ end }}',
sendResolved: true,
},
webhook: {
url: 'https://example.com/story-hook',
username: 'story-user',
sendResolved: true,
},
pagerduty: {
routingKey: 'story-routing-key',
description: '{{ .CommonLabels.alertname }}',
severity: 'critical',
sendResolved: true,
},
opsgenie: {
apiKey: 'story-opsgenie-key',
message: '{{ .CommonLabels.alertname }}',
description: 'Story channel',
sendResolved: true,
},
email: {
to: 'oncall@story.signoz.io',
html: '<p>{{ .CommonLabels.alertname }}</p>',
sendResolved: true,
},
msteams: {
webhookUrl: 'https://story.webhook.office.com/webhookb2/story',
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}',
text: 'Story channel',
sendResolved: true,
},
googlechat: {
webhookUrl: 'https://chat.googleapis.com/v1/spaces/STORY/messages',
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}',
text: 'Story channel',
sendResolved: true,
},
jira: {
site: 'https://story.atlassian.net',
project: 'OPS',
issueType: 'Task',
email: 'story@signoz.io',
apiToken: 'story-jira-token',
summary: '{{ .CommonLabels.alertname }}',
sendResolved: true,
},
jsmops: {
apiKey: 'story-jsm-key',
message: '{{ .CommonLabels.alertname }}',
description: 'Story channel',
sendResolved: true,
},
incidentio: {
url: 'https://api.incident.io/v2/alert_events/http/story',
token: 'story-incidentio-token',
title: '{{ .CommonLabels.alertname }}',
sendResolved: true,
},
};
/** A single channel as the v2 API returns it, which the edit screen reads. */
export const channelResponse = (
id: string,
type: ChannelType,
@@ -727,6 +833,36 @@ export const channelResponse = (
};
};
/** The v2 fetch by id, which is what the redesigned screens read. */
export const notificationChannelResponse = (
id: string,
type: ChannelType,
): {
status: string;
data: AlertmanagertypesGettableNotificationChannelDTO;
} => {
const index = Math.max(
CHANNEL_SEEDS.findIndex((seed) => seed.type === type),
0,
);
const seed = CHANNEL_SEEDS[index];
return {
status: 'success',
data: {
id,
name: seed.name,
displayName: seed.name,
createdAt: ago((index + 10) * DAY),
updatedAt: ago((index + 1) * DAY),
config: {
kind: type,
spec: CHANNEL_SPECS[type],
} as unknown as AlertmanagertypesChannelConfigDTO,
},
};
};
export const CHANNEL_ACTION_OUTCOMES = ['succeeds', 'fails'] as const;
export type ChannelActionOutcome = (typeof CHANNEL_ACTION_OUTCOMES)[number];

View File

@@ -21,7 +21,7 @@ import {
alertPreviewSeries,
} from '../../AlertList/stories/__story_mockdata__/alertQuery';
import {
channelsResponse,
notificationChannelsResponse,
CHANNEL_MAX,
} from '../../AlertList/stories/__story_mockdata__/alerts';
@@ -127,8 +127,8 @@ export const createAlertMocks = defineStoryMocks({
),
rest.get(
'http://localhost/api/v1/channels',
response.json(() => channelsResponse(values.channels)),
'http://localhost/api/v2/notification_channels',
response.json(() => notificationChannelsResponse(values.channels)),
),
rest.post(

View File

@@ -12,7 +12,10 @@ import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import { defineJsonEditorTheme, JSON_EDITOR_THEME } from './editorTheme';
import {
defineJsonEditorTheme,
JSON_EDITOR_THEME,
} from 'lib/monaco/jsonEditorTheme';
import styles from './JsonEditorDrawer.module.scss';
import JsonEditorToolbar from './JsonEditorToolbar';
import { useJsonEditor } from './useJsonEditor';

View File

@@ -15,6 +15,7 @@ import {
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import {
getPartialTimestamps,
hasSingleVisiblePoint,
toClickPluginPayload,
} from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
@@ -155,6 +156,7 @@ function addSeries({
fillOpacity,
isDarkMode,
metric: s.labels,
partialTimestamps: getPartialTimestamps(s.values),
});
});
}

View File

@@ -0,0 +1,34 @@
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { UTC_TIMEZONE } from 'components/CustomTimePicker/timezoneUtils';
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
import { PanelMode } from 'lib/visualization/panels/types';
import { buildBarChartConfig } from '../buildConfig';
describe('buildBarChartConfig partial buckets', () => {
it('passes partial timestamps (epoch seconds) to the series', () => {
const config = buildBarChartConfig({
panelId: 'p1',
spec: {} as DashboardtypesBarChartPanelSpecDTO,
builderQueries: [],
series: [
{
queryName: 'A',
legend: 'A',
labels: {},
values: [
{ timestamp: 1000, value: 1 },
{ timestamp: 61000, value: 0.2, partial: true },
],
kind: 'series',
aggregation: { index: 0, alias: 'A' },
},
],
isDarkMode: false,
timezone: UTC_TIMEZONE,
panelMode: PanelMode.DASHBOARD_VIEW,
}).getConfig().series?.[1] as ExtendedSeries;
expect(config.partialTimestamps).toStrictEqual(new Set([61]));
});
});

View File

@@ -5,7 +5,10 @@ import {
} from 'pages/DashboardPage/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { toClickPluginPayload } from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
import {
getPartialTimestamps,
toClickPluginPayload,
} from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
import getLabelName from 'lib/getLabelName';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
@@ -102,6 +105,7 @@ function addSeries({
isDarkMode,
stepInterval,
metric: s.labels,
partialTimestamps: getPartialTimestamps(s.values),
});
});
}

View File

@@ -1,5 +1,6 @@
import type { DashboardtypesTimeSeriesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { UTC_TIMEZONE } from 'components/CustomTimePicker/timezoneUtils';
import type { ExtendedSeries } from 'lib/uPlotV2/config/types';
import { PanelMode } from 'lib/visualization/panels/types';
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
@@ -74,3 +75,27 @@ describe('buildTimeSeriesConfig spanGaps', () => {
).toStrictEqual([10]);
});
});
describe('buildTimeSeriesConfig partial buckets', () => {
it('passes partial timestamps (epoch seconds) to the series', () => {
const config = buildTimeSeriesConfig({
panelId: 'p1',
spec: {} as DashboardtypesTimeSeriesPanelSpecDTO,
builderQueries: [],
series: [
{
...series[0],
values: [
{ timestamp: 1000, value: 1 },
{ timestamp: 61000, value: 0.2, partial: true },
],
},
],
isDarkMode: false,
timezone: UTC_TIMEZONE,
panelMode: PanelMode.DASHBOARD_VIEW,
}).getConfig().series?.[1] as ExtendedSeries;
expect(config.partialTimestamps).toStrictEqual(new Set([61]));
});
});

View File

@@ -13,6 +13,7 @@ import { resolveSpanGaps } from 'pages/DashboardPage/DashboardContainer/Panels/u
import { resolveSeriesLabelV5 } from 'pages/DashboardPage/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import {
getPartialTimestamps,
hasSingleVisiblePoint,
toClickPluginPayload,
} from 'pages/DashboardPage/DashboardContainer/queryV5/uplotData';
@@ -149,6 +150,7 @@ function addSeries({
fillMode,
isDarkMode,
metric: s.labels,
partialTimestamps: getPartialTimestamps(s.values),
});
});
}

View File

@@ -1,5 +1,6 @@
import type { PanelSeries } from '../types';
import {
getPartialTimestamps,
hasSingleVisiblePoint,
prepareAlignedData,
toClickPluginPayload,
@@ -85,6 +86,22 @@ describe('hasSingleVisiblePoint', () => {
});
});
describe('getPartialTimestamps', () => {
it('returns partial timestamps in epoch seconds', () => {
expect(
getPartialTimestamps([
{ timestamp: 1_000_500, value: 1, partial: true },
{ timestamp: 2_000_000, value: 2 },
{ timestamp: 3_000_000, value: 3, partial: true },
]),
).toStrictEqual(new Set([1000, 3000]));
});
it('returns undefined when nothing is partial', () => {
expect(getPartialTimestamps([{ timestamp: 1000, value: 1 }])).toBeUndefined();
});
});
describe('toClickPluginPayload', () => {
it('produces the tuple-shaped legacy result the shared click plugin reads', () => {
const payload = toClickPluginPayload([

View File

@@ -41,6 +41,19 @@ export function prepareAlignedData(series: PanelSeries[]): uPlot.AlignedData {
return [timestamps, ...yValues] as uPlot.AlignedData;
}
/** Epoch-second x values of the series' partial buckets; `undefined` when none are partial. */
export function getPartialTimestamps(
values: PanelSeriesPoint[],
): Set<number> | undefined {
const timestamps = new Set<number>();
values.forEach((point) => {
if (point.partial) {
timestamps.add(Math.floor(point.timestamp / 1000));
}
});
return timestamps.size ? timestamps : undefined;
}
/**
* True when the series has at most one finite point — can't draw a line, so renderers degrade to
* points (V1 `hasSingleVisiblePoint` parity).

View File

@@ -20,7 +20,7 @@ import {
import {
alertRuleByIdResponse,
ALERT_SCHEMAS,
channelsResponse,
notificationChannelsResponse,
CHANNEL_MAX,
type AlertSchema,
} from '../../AlertList/stories/__story_mockdata__/alerts';
@@ -77,8 +77,8 @@ export const editRulesMocks = defineStoryMocks({
),
rest.get(
'http://localhost/api/v1/channels',
response.json(() => channelsResponse(values.channels)),
'http://localhost/api/v2/notification_channels',
response.json(() => notificationChannelsResponse(values.channels)),
),
rest.post(

View File

@@ -21,6 +21,8 @@ export interface QueryData {
queryName: string;
legend?: string;
values: [number, string][];
/** Epoch-second x values (as in `values`) of buckets that don't cover a full step. */
partialTimestamps?: number[];
quantity?: number[];
unit?: string;
table?: {
@@ -45,7 +47,7 @@ export interface SeriesItem {
[key: string]: string;
};
labelsArray: { [key: string]: string }[];
values: { timestamp: number; value: string }[];
values: { timestamp: number; value: string; partial?: boolean }[];
metaData?: {
alias: string;
index: number;

View File

@@ -4,14 +4,16 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/gorilla/mux"
)
func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/infra_monitoring/hosts", handler.New(
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListHosts),
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListHosts, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListHosts",
Tags: []string{"inframonitoring"},
@@ -24,13 +26,18 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/pods", handler.New(
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListPods),
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListPods, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListPods",
Tags: []string{"inframonitoring"},
@@ -43,13 +50,18 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/kube_containers", handler.New(
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListContainers),
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListContainers, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListContainers",
Tags: []string{"inframonitoring"},
@@ -62,13 +74,18 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/nodes", handler.New(
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListNodes),
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListNodes, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListNodes",
Tags: []string{"inframonitoring"},
@@ -81,13 +98,18 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/namespaces", handler.New(
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListNamespaces),
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListNamespaces, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListNamespaces",
Tags: []string{"inframonitoring"},
@@ -100,13 +122,18 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/clusters", handler.New(
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListClusters),
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListClusters, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListClusters",
Tags: []string{"inframonitoring"},
@@ -119,13 +146,18 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/pvcs", handler.New(
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListVolumes),
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListVolumes, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListVolumes",
Tags: []string{"inframonitoring"},
@@ -138,13 +170,18 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/deployments", handler.New(
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListDeployments),
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListDeployments, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListDeployments",
Tags: []string{"inframonitoring"},
@@ -157,13 +194,18 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/statefulsets", handler.New(
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListStatefulSets),
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListStatefulSets, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListStatefulSets",
Tags: []string{"inframonitoring"},
@@ -176,13 +218,18 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/jobs", handler.New(
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListJobs),
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListJobs, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListJobs",
Tags: []string{"inframonitoring"},
@@ -195,13 +242,18 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/daemonsets", handler.New(
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.ListDaemonSets),
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.ListDaemonSets, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListDaemonSets",
Tags: []string{"inframonitoring"},
@@ -214,13 +266,18 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/checks", handler.New(
provider.authzMiddleware.ViewAccess(provider.infraMonitoringHandler.GetChecks),
provider.authzMiddleware.CheckResources(provider.infraMonitoringHandler.GetChecks, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetChecks",
Tags: []string{"inframonitoring"},
@@ -232,8 +289,13 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWildcardResources(coretypes.ResourceTelemetryResourceMetrics),
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}

View File

@@ -84,5 +84,26 @@ func (provider *provider) addPromoteRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/promoted_paths/materialized", handler.New(provider.authzMiddleware.CheckResources(provider.promoteHandler.IndexMaterializedPaths, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName), handler.OpenAPIDef{
ID: "IndexMaterializedPaths",
Tags: []string{"promote"},
Summary: "Index materialized paths",
Description: "This endpoint indexes the JSON sub-columns of materialized paths whose columns are indexed and returns the indexed and skipped paths.",
Request: new(promotetypes.PostableIndexMaterializedPaths),
RequestContentType: "application/json",
Response: new(promotetypes.IndexMaterializedPathsResult),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceLogsField.Scope(coretypes.VerbUpdate), coretypes.ResourceMetaResourceTracesField.Scope(coretypes.VerbUpdate)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
Resources: promotetypes.IndexMaterializedPathsResources,
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -72,3 +72,23 @@ func (h *handler) ListPromotedPaths(w http.ResponseWriter, r *http.Request) {
render.Success(w, http.StatusOK, paths)
}
func (h *handler) IndexMaterializedPaths(w http.ResponseWriter, r *http.Request) {
var req promotetypes.PostableIndexMaterializedPaths
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
render.Error(w, err)
return
}
if err := req.Validate(); err != nil {
render.Error(w, err)
return
}
paths, err := h.module.IndexMaterializedPaths(r.Context(), req)
if err != nil {
render.Error(w, err)
return
}
render.Success(w, http.StatusOK, paths)
}

View File

@@ -2,10 +2,12 @@ package implpromote
import (
"context"
"log/slog"
"strings"
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/promote"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
@@ -22,10 +24,15 @@ var (
type module struct {
metadataStore telemetrytypes.MetadataStore
telemetryStore telemetrystore.TelemetryStore
settings factory.ScopedProviderSettings
}
func NewModule(metadataStore telemetrytypes.MetadataStore, telemetrystore telemetrystore.TelemetryStore) promote.Module {
return &module{metadataStore: metadataStore, telemetryStore: telemetrystore}
func NewModule(metadataStore telemetrytypes.MetadataStore, telemetrystore telemetrystore.TelemetryStore, providerSettings factory.ProviderSettings) promote.Module {
return &module{
metadataStore: metadataStore,
telemetryStore: telemetrystore,
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/promote/implpromote"),
}
}
func (m *module) ListPromotedPaths(ctx context.Context, filters promotetypes.ListPromotedPathsFilters) ([]promotetypes.PromotePath, error) {
@@ -101,6 +108,71 @@ func (m *module) listPromotedPaths(ctx context.Context, target promotetypes.Targ
return response, nil
}
func (m *module) IndexMaterializedPaths(ctx context.Context, req promotetypes.PostableIndexMaterializedPaths) (*promotetypes.IndexMaterializedPathsResult, error) {
target, err := req.Target()
if err != nil {
return nil, err
}
keys, err := m.metadataStore.GetMaterializedKeys(ctx, target.Entry.Signal)
if err != nil {
return nil, err
}
paths := []*promotetypes.PromotePath{}
names := []string{}
for _, key := range keys {
if key.FieldContext != target.Entry.FieldContext || len(key.Indexes) == 0 {
continue
}
path, err := promotetypes.NewMaterializedPromotePath(target, key)
if err != nil {
m.settings.Logger().WarnContext(ctx, "skipping materialized path that cannot be indexed", slog.String("path", key.Name), errors.Attr(err))
continue
}
paths = append(paths, path)
names = append(names, path.Path)
}
result := &promotetypes.IndexMaterializedPathsResult{Indexed: []*promotetypes.PromotePath{}, Skipped: []*promotetypes.PromotePath{}}
if len(paths) > 0 {
existing, err := m.metadataStore.ListJSONIndexes(ctx, target.JSONIndexLookup(), names...)
if err != nil {
return nil, err
}
existingByPath := map[string][]promotetypes.WrappedIndex{}
for _, index := range existing {
existingByPath[index.Name] = append(existingByPath[index.Name], promotetypes.WrappedIndex{FieldDataType: index.FieldDataType, Type: index.IndexType, Granularity: index.Granularity})
}
for _, path := range paths {
if indexes, ok := existingByPath[path.Path]; ok {
path.Indexes = indexes
result.Skipped = append(result.Skipped, path)
continue
}
result.Indexed = append(result.Indexed, path)
}
}
if !req.DryRun && len(result.Indexed) > 0 {
if err := m.promotePaths(ctx, target, result.Indexed...); err != nil {
return nil, err
}
}
indexed := make([]string, 0, len(result.Indexed))
for _, path := range result.Indexed {
indexed = append(indexed, path.Path)
}
skipped := make([]string, 0, len(result.Skipped))
for _, path := range result.Skipped {
skipped = append(skipped, path.Path)
}
m.settings.Logger().InfoContext(ctx, "indexed materialized paths", slog.String("signal", req.Signal), slog.Bool("dry_run", req.DryRun), slog.Int("indexed_count", len(indexed)), slog.Any("indexed", indexed), slog.Int("skipped_count", len(skipped)), slog.Any("skipped", skipped))
return result, nil
}
func (m *module) PromotePaths(ctx context.Context, paths ...*promotetypes.PromotePath) error {
byTarget := map[promotetypes.Target][]*promotetypes.PromotePath{}
targets := []promotetypes.Target{}
@@ -181,7 +253,7 @@ func (m *module) promotePaths(ctx context.Context, target promotetypes.Target, p
return err
}
indexes = append(indexes, schemamigrator.Index{
Name: schemamigrator.JSONSubColumnIndexName(parentColumn, it.Path, index.JSONDataType.StringValue(), typeIndex),
Name: schemamigrator.JSONSubColumnIndexName(parentColumn, it.Path, strings.ToLower(index.JSONDataType.StringValue()), typeIndex),
Expression: target.IndexExpression(parentColumn, it.Path, index.JSONDataType.StringValue()),
Type: index.Type,
Granularity: index.Granularity,

View File

@@ -4,14 +4,20 @@ import (
"context"
"regexp"
"testing"
"time"
sqlmock "github.com/DATA-DOG/go-sqlmock"
"github.com/huandu/go-sqlbuilder"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
"github.com/SigNoz/signoz/pkg/types/promotetypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
)
@@ -71,7 +77,7 @@ func TestPromotePaths(t *testing.T) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
store := telemetrytypestest.NewMockMetadataStore()
m := NewModule(store, nil)
m := NewModule(store, nil, factorytest.NewSettings())
err := m.PromotePaths(ctx, testCase.paths...)
if testCase.wantErr {
@@ -149,7 +155,7 @@ func TestPromotePathsCreatesIndexes(t *testing.T) {
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1, JSONDataType: telemetrytypes.String},
},
},
wantDDLColumn: "`attributes_promoted.http.method_String_ngrambf_v1` attributes_promoted.`http.method`::String",
wantDDLColumn: "`attributes_promoted.http.method_string_ngrambf_v1` attributes_promoted.`http.method`::String",
},
{
name: "TracesUnpromotedPath_IndexesBaseColumn",
@@ -161,7 +167,7 @@ func TestPromotePathsCreatesIndexes(t *testing.T) {
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "ngrambf_v1(4, 1024, 2, 0)", Granularity: 1, JSONDataType: telemetrytypes.String},
},
},
wantDDLColumn: "`attributes.http.method_String_ngrambf_v1` attributes.`http.method`::String",
wantDDLColumn: "`attributes.http.method_string_ngrambf_v1` attributes.`http.method`::String",
},
{
name: "BloomFilterIndex_FullTypeInDDL",
@@ -173,7 +179,7 @@ func TestPromotePathsCreatesIndexes(t *testing.T) {
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "bloom_filter(0.01)", Granularity: 1, JSONDataType: telemetrytypes.String},
},
},
wantDDLColumn: "`body_v2.user.name_String_bloom_filter` lower(assumeNotNull(dynamicElement(body_v2.user.name, 'String'))) TYPE bloom_filter(0.01)",
wantDDLColumn: "`body_v2.user.name_string_bloom_filter` lower(assumeNotNull(dynamicElement(body_v2.user.name, 'String'))) TYPE bloom_filter(0.01)",
},
{
name: "SetIndex_FullTypeInDDL",
@@ -185,7 +191,7 @@ func TestPromotePathsCreatesIndexes(t *testing.T) {
{FieldDataType: telemetrytypes.FieldDataTypeString, Type: "set(100)", Granularity: 1, JSONDataType: telemetrytypes.String},
},
},
wantDDLColumn: "`body_v2.user.name_String_set` lower(assumeNotNull(dynamicElement(body_v2.user.name, 'String'))) TYPE set(100)",
wantDDLColumn: "`body_v2.user.name_string_set` lower(assumeNotNull(dynamicElement(body_v2.user.name, 'String'))) TYPE set(100)",
},
}
@@ -194,7 +200,7 @@ func TestPromotePathsCreatesIndexes(t *testing.T) {
ts := telemetrystoretest.New(telemetrystore.Config{}, sqlmock.QueryMatcherRegexp)
store := telemetrytypestest.NewMockMetadataStore()
store.PromotedPathsMap[telemetrytypes.SignalLogs] = testCase.promoted
m := NewModule(store, ts)
m := NewModule(store, ts, factorytest.NewSettings())
ts.Mock().ExpectExec("ADD INDEX (.+)" + regexp.QuoteMeta(testCase.wantDDLColumn)).WillReturnError(nil)
require.NoError(t, m.PromotePaths(ctx, testCase.path))
@@ -390,7 +396,7 @@ func TestListPromotedPaths(t *testing.T) {
store := telemetrytypestest.NewMockMetadataStore()
store.PromotedPathsMap = testCase.promoted
store.LogsJSONIndexes = testCase.indexes
m := NewModule(store, nil)
m := NewModule(store, nil, factorytest.NewSettings())
paths, err := m.ListPromotedPaths(ctx, testCase.filters)
require.NoError(t, err)
@@ -408,3 +414,135 @@ func TestListPromotedPaths(t *testing.T) {
})
}
}
func TestIndexMaterializedPaths(t *testing.T) {
ctx := context.Background()
bloomIndex := []telemetrytypes.TelemetryFieldKeySkipIndex{{IndexType: "bloom_filter", Granularity: 4}}
materializedKeys := []*telemetrytypes.TelemetryFieldKey{
{Name: "user.id", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString, Indexes: bloomIndex},
{Name: "tenant.id", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
{Name: "retry.count", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeNumber, Indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{{IndexType: "minmax", Granularity: 1}}},
{Name: "k8s.pod.name", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString, Indexes: bloomIndex},
}
userIDDDL := "`attributes.user.id_string_bloom_filter` attributes.`user.id`::String TYPE bloom_filter GRANULARITY 4"
retryCountDDL := "`attributes.retry.count_float64_minmax` toFloat64OrNull(attributes.`retry.count`::String) TYPE minmax GRANULARITY 1"
testCases := []struct {
name string
req promotetypes.PostableIndexMaterializedPaths
keys []*telemetrytypes.TelemetryFieldKey
existing []telemetrytypes.TelemetryFieldKeySkipIndex
wantIndexed []string
wantSkipped []string
wantDDL []string
wantErr bool
}{
{
name: "DryRun_NoDDL",
req: promotetypes.PostableIndexMaterializedPaths{Signal: "traces", DryRun: true},
keys: materializedKeys,
wantIndexed: []string{"user.id", "retry.count"},
wantSkipped: []string{},
},
{
name: "Run_IndexesBaseColumn",
req: promotetypes.PostableIndexMaterializedPaths{Signal: "traces"},
keys: materializedKeys,
wantIndexed: []string{"user.id", "retry.count"},
wantSkipped: []string{},
wantDDL: []string{userIDDDL, retryCountDDL},
},
{
name: "AnyIndexBuilt_Skipped",
req: promotetypes.PostableIndexMaterializedPaths{Signal: "traces"},
keys: materializedKeys,
existing: []telemetrytypes.TelemetryFieldKeySkipIndex{{Name: "user.id", FieldContext: telemetrytypes.FieldContextAttribute, IndexType: "ngrambf_v1(4, 1024, 2, 0)"}},
wantIndexed: []string{"retry.count"},
wantSkipped: []string{"user.id"},
wantDDL: []string{retryCountDDL},
},
{
name: "UnsupportedIndexType_LoggedAndLeftOut",
req: promotetypes.PostableIndexMaterializedPaths{Signal: "traces"},
keys: append([]*telemetrytypes.TelemetryFieldKey{
{Name: "user.email", Signal: telemetrytypes.SignalTraces, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString, Indexes: []telemetrytypes.TelemetryFieldKeySkipIndex{{IndexType: "text(tokenizer = 'splitByNonAlpha')", Granularity: 1}}},
}, materializedKeys...),
wantIndexed: []string{"user.id", "retry.count"},
wantSkipped: []string{},
wantDDL: []string{userIDDDL, retryCountDDL},
},
{
name: "LogsSignal_NoAttributeTarget_Rejected",
req: promotetypes.PostableIndexMaterializedPaths{Signal: "logs"},
wantErr: true,
},
{
name: "MissingSignal_Rejected",
wantErr: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
ts := telemetrystoretest.New(telemetrystore.Config{}, sqlmock.QueryMatcherRegexp)
store := telemetrytypestest.NewMockMetadataStore()
store.MaterializedKeys = testCase.keys
store.LogsJSONIndexes = testCase.existing
m := NewModule(store, ts, factorytest.NewSettings())
for _, ddl := range testCase.wantDDL {
ts.Mock().ExpectExec("ADD INDEX (.+)" + regexp.QuoteMeta(ddl)).WillReturnError(nil)
}
result, err := m.IndexMaterializedPaths(ctx, testCase.req)
if testCase.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
indexed := []string{}
for _, path := range result.Indexed {
assert.False(t, path.Promote)
indexed = append(indexed, path.Path)
}
skipped := []string{}
for _, path := range result.Skipped {
skipped = append(skipped, path.Path)
}
assert.Equal(t, testCase.wantIndexed, indexed)
assert.Equal(t, testCase.wantSkipped, skipped)
assert.NoError(t, ts.Mock().ExpectationsWereMet())
})
}
}
// An index is used only when a filter's left-hand side is its exact expression.
func TestTracesNumberIndexExpr_MatchesQuerierFilters(t *testing.T) {
release := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
queryInfo := qbtypes.QueryInfo{StartNs: uint64(release.AddDate(0, 6, 0).UnixNano()), EndNs: uint64(release.AddDate(0, 7, 0).UnixNano()), TraceAttrsJSONOn: true}
key := telemetrytypes.TelemetryFieldKey{Name: "http.status_code", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeNumber, Evolutions: tracestelemetryschema.MockAttributeEvolutionData(release)}
indexExpr := promotetypes.NewTracesAttributesTarget().IndexExpression("attributes", "http.status_code", telemetrytypes.Float64.StringValue())
testCases := []struct {
name string
operator qbtypes.FilterOperator
value any
want string
}{
{name: "Equal", operator: qbtypes.FilterOperatorEqual, value: float64(200), want: indexExpr + " = ?"},
{name: "In", operator: qbtypes.FilterOperatorIn, value: []any{float64(200), float64(404)}, want: indexExpr + " = ? OR " + indexExpr + " = ?"},
{name: "GreaterThan", operator: qbtypes.FilterOperatorGreaterThan, value: float64(200), want: indexExpr + " > ?"},
{name: "Between", operator: qbtypes.FilterOperatorBetween, value: []any{float64(200), float64(299)}, want: indexExpr + " BETWEEN ? AND ?"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
conditions, _, err := querybuilder.Conditions(context.Background(), queryInfo, tracestelemetryschema.NewStorage(), &key, testCase.operator, testCase.value, map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {&key}}, false, sb)
require.NoError(t, err)
sb.Where(conditions...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Contains(t, sql, testCase.want)
})
}
}

View File

@@ -10,9 +10,12 @@ import (
type Module interface {
ListPromotedPaths(ctx context.Context, filters promotetypes.ListPromotedPathsFilters) ([]promotetypes.PromotePath, error)
PromotePaths(ctx context.Context, paths ...*promotetypes.PromotePath) error
IndexMaterializedPaths(ctx context.Context, req promotetypes.PostableIndexMaterializedPaths) (*promotetypes.IndexMaterializedPathsResult, error)
}
type Handler interface {
PromotePaths(w http.ResponseWriter, r *http.Request)
ListPromotedPaths(w http.ResponseWriter, r *http.Request)
IndexMaterializedPaths(w http.ResponseWriter, r *http.Request)
}

File diff suppressed because one or more lines are too long

View File

@@ -7,6 +7,7 @@ import (
"reflect"
"regexp"
"strconv"
"strings"
schema "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/clickhousesql"
@@ -135,6 +136,8 @@ func DataTypeCollisionHandledFieldName(key *telemetrytypes.TelemetryFieldKey, va
// This following is one way to workaround it
// if the key is a number, the value is a string, we will let clickHouse handle the conversion
// and clickhouse float64 has a 53-bit mantissa, so values above 2^53 rounds to the nearest representable double
// breaking the equality filters for large integers
case float32, float64:
tblFieldName = castFloatHack(tblFieldName)
case string:
@@ -207,14 +210,22 @@ func ColumnIsTemporal(col *schema.Column) bool {
return false
}
func castFloat(col string) string { return fmt.Sprintf("toFloat64OrNull(%s)", col) }
func castFloatHack(col string) string { return fmt.Sprintf("toFloat64(%s)", col) }
func castString(col string) string { return fmt.Sprintf("toString(%s)", col) }
func castBool(col string) string { return fmt.Sprintf("accurateCastOrNull(%s, 'Bool')", col) }
func castFloat(col string) string { return fmt.Sprintf("toFloat64OrNull(%s)", col) }
func castString(col string) string { return fmt.Sprintf("toString(%s)", col) }
func castBool(col string) string { return fmt.Sprintf("accurateCastOrNull(%s, 'Bool')", col) }
func accurateCastFloat(col string) string {
return fmt.Sprintf("accurateCastOrNull(%s, 'Float64')", col)
}
// castFloatHack leaves a read already cast to Float64 bare: the wrapper would hide it from
// its skip index.
func castFloatHack(col string) string {
if strings.HasPrefix(col, "toFloat64OrNull(") {
return col
}
return fmt.Sprintf("toFloat64(%s)", col)
}
func allFloats(in []any) bool {
for _, x := range in {
if _, ok := x.(float64); !ok {

View File

@@ -85,6 +85,18 @@ func PromQLResources(coretypes.ExtractorContext) ([]coretypes.ResourceWithID, er
}}, nil
}
func BuilderQueryWithFilterExpressionResources(resource coretypes.Resource, bodyPath string) coretypes.ResourceExtractor {
return func(ec coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
return builderResources(qbtypes.QueryTypeBuilder.StringValue(), resource, gjson.GetBytes(ec.RequestBody, bodyPath).String(), nil)
}
}
func BuilderQueryWildcardResources(resource coretypes.Resource) coretypes.ResourceExtractor {
return func(coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
return builderResources(qbtypes.QueryTypeBuilder.StringValue(), resource, "", nil)
}
}
func resourcesForQuery(query gjson.Result, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
queryType := query.Get("type").String()
typeWildcard := queryType + "/" + coretypes.WildCardSelectorString
@@ -120,7 +132,11 @@ func resourcesForBuilderQuery(queryType string, spec gjson.Result, variables map
}
func builderQueryResourceRefs(queryType string, resource coretypes.Resource, spec gjson.Result, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
ids, err := builderQuerySelectors(queryType, spec.Get("filter.expression").String(), variables)
return builderResources(queryType, resource, spec.Get("filter.expression").String(), variables)
}
func builderResources(queryType string, resource coretypes.Resource, expression string, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
ids, err := builderQuerySelectors(queryType, expression, variables)
if err != nil {
return nil, err
}

View File

@@ -195,3 +195,46 @@ func TestTelemetrySelector(t *testing.T) {
// a value containing "/" stays one logical segment (SplitN 3).
assert.Equal(t, []string{"builder_query/signoz.workspace.key.id/a/b", "builder_query/signoz.workspace.key.id/*", "builder_query/*", "*"}, selectorValues("builder_query/signoz.workspace.key.id/a/b"))
}
func TestBuilderQueryWithFilterExpressionResources(t *testing.T) {
testCases := []struct {
name string
body string
expected []coretypes.ResourceWithID
}{
{
name: "FilterPresent_ScopedID",
body: `{"start":1,"end":2,"filter":{"expression":"signoz.workspace.key.id = 'key-a'"}}`,
expected: []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/signoz.workspace.key.id/key-a"}},
},
{
name: "FilterAbsent_Wildcard",
body: `{"start":1,"end":2}`,
expected: []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/*"}},
},
{
name: "EmptyBody_Wildcard",
body: ``,
expected: []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/*"}},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
refs, err := BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceTraces, "filter.expression")(coretypes.ExtractorContext{RequestBody: []byte(testCase.body)})
require.NoError(t, err)
assert.Equal(t, testCase.expected, refs)
})
}
t.Run("InvalidExpression_Error", func(t *testing.T) {
_, err := BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceTraces, "filter.expression")(coretypes.ExtractorContext{RequestBody: []byte(`{"filter":{"expression":"signoz.workspace.key.id = "}}`)})
require.Error(t, err)
})
}
func TestBuilderQueryWildcardResources(t *testing.T) {
refs, err := BuilderQueryWildcardResources(coretypes.ResourceTelemetryResourceTraces)(coretypes.ExtractorContext{})
require.NoError(t, err)
assert.Equal(t, []coretypes.ResourceWithID{{Resource: coretypes.ResourceTelemetryResourceTraces, ID: "builder_query/*"}}, refs)
}

View File

@@ -155,7 +155,7 @@ func NewModules(
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
MetricReductionRule: metricReductionRule,
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore, providerSettings),
ServiceAccount: serviceAccount,
ServiceAccountGetter: serviceAccountGetter,
LogsPipeline: impllogspipeline.NewModule(sqlstore),

View File

@@ -38,15 +38,22 @@ var logsBodyPromotedEntry = promotetypes.NewLogsBodyTarget().Entry
var logsBodyIndexLookup = promotetypes.NewLogsBodyTarget().JSONIndexLookup()
// ClickHouse stores a `col.path::Type` skip index expression as CAST(col.path, 'Type').
var simpleJSONSubColumnIndexExprRe = regexp.MustCompile(`^CAST\((?P<expr>[^()]+), '(?P<type>.+)'\)$`)
// ClickHouse stores a `col.path::Type` skip index expression as CAST(col.path, 'Type');
// a backtick-quoted path may hold any character, parentheses included.
var simpleJSONSubColumnIndexExprRe = regexp.MustCompile("^CAST\\((?P<expr>(?:[^()`]|`[^`]*`)+), '(?P<type>.+)'\\)$")
// unfoldJSONSubColumnIndexExpr accepts both the folded (lower/assumeNotNull)
// and the bare type-cast expression forms.
// ClickHouse stores a traces number index expression with its ::String unfolded to CAST.
var jsonNumberSubColumnIndexExprRe = regexp.MustCompile("^toFloat64OrNull\\(CAST\\((?P<expr>(?:[^()`]|`[^`]*`)+), 'String'\\)\\)$")
// unfoldJSONSubColumnIndexExpr accepts the folded (lower/assumeNotNull), the
// traces number and the bare type-cast expression forms.
func unfoldJSONSubColumnIndexExpr(expr string) (string, string, error) {
if columnExpr, columnType, err := schemamigrator.UnfoldJSONSubColumnIndexExpr(expr); err == nil {
return columnExpr, columnType, nil
}
if matches := jsonNumberSubColumnIndexExprRe.FindStringSubmatch(expr); matches != nil {
return matches[1], telemetrytypes.Float64.StringValue(), nil
}
matches := simpleJSONSubColumnIndexExprRe.FindStringSubmatch(expr)
if matches == nil {
return "", "", errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid expression: %s", expr)
@@ -198,7 +205,8 @@ func (t *telemetryMetaStore) ListJSONIndexes(ctx context.Context, lookup telemet
columnExpr, columnType, err := unfoldJSONSubColumnIndexExpr(expr)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to unfold JSON sub column index expression: %s", expr)
t.logger.WarnContext(ctx, "skipping JSON sub column index with unrecognized expression", slog.String("index", name), slog.String("expr", expr), errors.Attr(err))
continue
}
fdt, found := telemetrytypes.MappingJSONDataTypeToFieldDataType[columnType]

View File

@@ -92,6 +92,23 @@ func TestUnfoldJSONSubColumnIndexExpr(t *testing.T) {
wantColumn: "attributes_promoted.status",
wantType: "Int64",
},
{
name: "TracesNumber",
expr: "toFloat64OrNull(CAST(attributes.`http.status_code`, 'String'))",
wantColumn: "attributes.`http.status_code`",
wantType: "Float64",
},
{
name: "TracesNumber_BacktickedParentheses",
expr: "toFloat64OrNull(CAST(attributes.`latency(ms)`, 'String'))",
wantColumn: "attributes.`latency(ms)`",
wantType: "Float64",
},
{
name: "TracesNumber_NotAStringCast",
expr: "toFloat64OrNull(CAST(attributes.a, 'Int64'))",
wantErr: true,
},
{
name: "UnrecognizedForm",
expr: "toString(attributes.status)",
@@ -137,9 +154,25 @@ func TestUnfoldJSONSubColumnIndexExprTracesRoundTrip(t *testing.T) {
name: "PlainKey_UnquotedInStorage",
column: "attributes_promoted",
path: "status",
jsonDataType: "Int64",
wantExpr: "attributes_promoted.`status`::Int64",
storedExpr: "CAST(attributes_promoted.status, 'Int64')",
jsonDataType: "String",
wantExpr: "attributes_promoted.`status`::String",
storedExpr: "CAST(attributes_promoted.status, 'String')",
},
{
name: "DottedNumber_BackticksPreservedInStorage",
column: "attributes",
path: "http.status_code",
jsonDataType: "Float64",
wantExpr: "toFloat64OrNull(attributes.`http.status_code`::String)",
storedExpr: "toFloat64OrNull(CAST(attributes.`http.status_code`, 'String'))",
},
{
name: "PlainNumber_UnquotedInStorage",
column: "attributes_promoted",
path: "latency",
jsonDataType: "Float64",
wantExpr: "toFloat64OrNull(attributes_promoted.`latency`::String)",
storedExpr: "toFloat64OrNull(CAST(attributes_promoted.latency, 'String'))",
},
}

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