Compare commits

..

17 Commits

Author SHA1 Message Date
Nikhil Mantri
1360c9507a Merge branch 'main' into alerts/clone_alert_rule_api 2026-10-16 19:08:47 +05:30
nikhilmantri0902
8b2f6003b2 refactor(ruler): add DisplayName valuer for alert names, revert dashboard clone changes 2026-10-16 19:05:04 +05:30
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
nikhilmantri0902
34d0c6a695 test(ruler): use reserved test domain for clone test webhook 2026-10-16 14:35:04 +05:30
nikhilmantri0902
9fe2fb549c refactor: move GenerateCloneName to valuer package 2026-10-16 14:29:12 +05:30
nikhilmantri0902
2131b9742f test(ruler): use v2 apis in clone test, drop redundant cases 2026-10-16 14:10:12 +05:30
Gaurav Tewari
c1cc58ac50 feat: use allow list in getSpanDisplayData (#13124)
<!--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

- Span details (pretty and JSON views) now shows an allow list of
queryable span fields instead of hiding a deny list.
- Hides rendering-only keys: `time_unix`, `level`, `has_children`,
`sub_tree_node_count`, `service.name`, and the
`formattedInput`/`formattedOutput`/`formatter`/`formatterWarnings`
fields from #13121.
- `attributes` and `resource` are shown in full.

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

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings
before - 
<img width="1637" height="1092" alt="image"
src="https://github.com/user-attachments/assets/fd7a3af8-c558-49d2-b120-ffbddf24924a"
/>
now - 
<img width="1683" height="1094" alt="image"
src="https://github.com/user-attachments/assets/01d18ac6-4b75-4d42-9c16-60d6f1911723"
/>


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

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

https://github.com/SigNoz/nerve-pod/issues/432#issuecomment-6164009733

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-10-16 06:39:46 +00:00
nikhilmantri0902
ac3462ceec refactor: limit shared helper to clone names, keep internal name generators per package 2026-10-16 11:27:21 +05:30
Nikhil Mantri
3ae9a944cc Merge branch 'main' into alerts/clone_alert_rule_api 2026-10-16 10:15:48 +05:30
nikhilmantri0902
9ce53c704e refactor: share name helpers in pkg/types, create cloned rule from PostableRule 2026-10-14 22:45:14 +05:30
Nikhil Mantri
17c4fbf2d4 Merge branch 'main' into alerts/clone_alert_rule_api 2026-10-14 22:00:29 +05:30
nikhilmantri0902
fe74d501ad chore(ruler): regenerate openapi spec for clone rule 2026-10-09 14:33:40 +05:30
nikhilmantri0902
49208e7b7e fix(ruler): name the source rule when clone validation fails 2026-10-09 14:33:39 +05:30
nikhilmantri0902
475196503b chore(ruler): trim comments in clone rule code 2026-10-09 14:25:32 +05:30
nikhilmantri0902
bd6a4075c8 chore: clone alert rule API 2026-10-09 13:59:19 +05:30
90 changed files with 1743 additions and 708 deletions

View File

@@ -18914,9 +18914,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 +18988,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 +19072,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 +19150,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 +19218,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 +19300,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 +19381,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 +19452,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 +19527,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 +19603,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 +19674,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 +19752,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: List StatefulSets for Infra Monitoring
tags:
- inframonitoring
@@ -24922,6 +24922,75 @@ paths:
tags:
- rules
x-signoz-stability: alpha
/api/v2/rules/{id}/clone:
post:
deprecated: false
description: This endpoint creates a new alert rule from the stored definition
of an existing one. The clone keeps every modeled field of the source, including
its enabled/disabled state, and gets the source's name suffixed with " - Copy"
(or a bumped " - Copy (n)" counter when the source is itself a copy). The
clone is owned by the caller.
operationId: CloneRuleByID
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/RuletypesRule'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Clone alert rule
tags:
- rules
x-signoz-stability: alpha
/api/v2/rules/{id}/history/filter_keys:
get:
deprecated: false

View File

@@ -423,7 +423,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
func (r *AnomalyRule) String() string {
ar := ruletypes.PostableRule{
AlertName: r.Name(),
AlertName: valuer.NewDisplayName(r.Name()),
RuleCondition: r.Condition(),
EvalWindow: r.EvalWindow(),
Labels: r.Labels().Map(),

View File

@@ -46,7 +46,7 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
target := 500.0
postableRule := ruletypes.PostableRule{
AlertName: "Test anomaly no data",
AlertName: valuer.NewDisplayName("Test anomaly no data"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -149,7 +149,7 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
target := 500.0
postableRule := ruletypes.PostableRule{
AlertName: "Test anomaly no data with AbsentFor",
AlertName: valuer.NewDisplayName("Test anomaly no data with AbsentFor"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{

View File

@@ -13,6 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) {
@@ -118,7 +119,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
}
parsedRule := opts.Rule
var alertname = parsedRule.AlertName
var alertname = parsedRule.AlertName.StringValue()
if alertname == "" {
// alertname is not mandatory for testing, so picking
// a random string here
@@ -126,7 +127,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
}
// append name to indicate this is test alert
parsedRule.AlertName = fmt.Sprintf("%s%s", alertname, ruletypes.TestAlertPostFix)
parsedRule.AlertName = valuer.NewDisplayName(fmt.Sprintf("%s%s", alertname, ruletypes.TestAlertPostFix))
var rule baserules.Rule
var err error

View File

@@ -18,6 +18,8 @@ import type {
} from 'react-query';
import type {
CloneRuleByID201,
CloneRuleByIDPathParameters,
CreateRule201,
CreateRuleView201,
DeleteRuleByIDPathParameters,
@@ -963,6 +965,85 @@ export const useUpdateRuleByID = <
> => {
return useMutation(getUpdateRuleByIDMutationOptions(options));
};
/**
* This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with " - Copy" (or a bumped " - Copy (n)" counter when the source is itself a copy). The clone is owned by the caller.
* @summary Clone alert rule
*/
export const cloneRuleByID = (
{ id }: CloneRuleByIDPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CloneRuleByID201>({
url: `/api/v2/rules/${id}/clone`,
method: 'POST',
signal,
});
};
export const getCloneRuleByIDMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
> => {
const mutationKey = ['cloneRuleByID'];
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 cloneRuleByID>>,
{ pathParams: CloneRuleByIDPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return cloneRuleByID(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type CloneRuleByIDMutationResult = NonNullable<
Awaited<ReturnType<typeof cloneRuleByID>>
>;
export type CloneRuleByIDMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Clone alert rule
*/
export const useCloneRuleByID = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof cloneRuleByID>>,
TError,
{ pathParams: CloneRuleByIDPathParameters },
TContext
> => {
return useMutation(getCloneRuleByIDMutationOptions(options));
};
/**
* Returns distinct label keys from rule history entries for the selected range.
* @summary Get rule history filter keys

View File

@@ -14582,6 +14582,17 @@ export type PatchRuleByID200 = {
export type UpdateRuleByIDPathParameters = {
id: string;
};
export type CloneRuleByIDPathParameters = {
id: string;
};
export type CloneRuleByID201 = {
data: RuletypesRuleDTO;
/**
* @type string
*/
status: string;
};
export type GetRuleHistoryFilterKeysPathParameters = {
id: string;
};

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

@@ -71,7 +71,6 @@ export const AIAssistantOpenSource = {
Shortcut: 'shortcut',
Cmdk: 'cmdk',
TraceDetails: 'trace_details',
BottomStrip: 'bottom_strip',
} as const;
export type AIAssistantOpenSource =
(typeof AIAssistantOpenSource)[keyof typeof AIAssistantOpenSource];

View File

@@ -1,15 +1,8 @@
import { useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import Noz from 'components/Noz/Noz';
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
import {
AIAssistantEvents,
AIAssistantOpenSource,
} from 'container/AIAssistant/events';
import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyticsContext';
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
import {
openAIAssistant,
@@ -44,14 +37,6 @@ function AskNoz(): JSX.Element | null {
// The drawer does not render on the Noz full page, so the button would be inert.
const isAIAssistantPage = pathname.startsWith(ROUTES.AI_ASSISTANT_BASE);
const handleOpen = useCallback((): void => {
void logEvent(AIAssistantEvents.Opened, {
source: AIAssistantOpenSource.BottomStrip,
currentPage: normalizePage(pathname),
});
openAIAssistant();
}, [pathname]);
if (!isAIAssistantEnabled || isDrawerOpen || isAIAssistantPage) {
return null;
}
@@ -72,7 +57,7 @@ function AskNoz(): JSX.Element | null {
color="secondary"
size="sm"
prefix={<Noz size={16} />}
onClick={handleOpen}
onClick={(): void => openAIAssistant()}
aria-label={
showPendingBadge
? `Ask Noz, ${pendingUserInputCount} ${

View File

@@ -1,9 +1,4 @@
import { fireEvent } from '@testing-library/react';
import logEvent from 'api/common/logEvent';
import {
AIAssistantEvents,
AIAssistantOpenSource,
} from 'container/AIAssistant/events';
import { openAIAssistant } from 'container/AIAssistant/store/useAIAssistantStore';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { render } from 'tests/test-utils';
@@ -11,10 +6,6 @@ import { render } from 'tests/test-utils';
import AskNoz from '../AskNoz';
jest.mock('hooks/useIsAIAssistantEnabled');
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('container/AIAssistant/store/useAIAssistantStore', () => ({
openAIAssistant: jest.fn(),
useAIAssistantStore: jest.fn(),
@@ -100,17 +91,6 @@ describe('AskNoz', () => {
expect(mockOpen).toHaveBeenCalledTimes(1);
});
it('reports the open as coming from the strip', () => {
const { getByRole } = render(<AskNoz />);
fireEvent.click(getByRole('button'));
expect(logEvent).toHaveBeenCalledWith(
AIAssistantEvents.Opened,
expect.objectContaining({ source: AIAssistantOpenSource.BottomStrip }),
);
});
});
describe('pending badge', () => {

View File

@@ -1,16 +1,9 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import logEvent from 'api/common/logEvent';
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { MessageSquareText } from '@signozhq/icons';
import {
BottomStripEventKeys,
BottomStripEvents,
BottomStripSupportType,
} from '../events';
function SupportButton(): JSX.Element | null {
const chatSupport = useChatSupport();
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
@@ -21,13 +14,7 @@ function SupportButton(): JSX.Element | null {
}
const handleClick = (): void => {
const needsCard = chatSupport === ChatSupportState.NeedsCard;
void logEvent(BottomStripEvents.SupportClicked, {
[BottomStripEventKeys.Type]: needsCard
? BottomStripSupportType.AddCard
: BottomStripSupportType.Pylon,
});
if (needsCard) {
if (chatSupport === ChatSupportState.NeedsCard) {
setIsAddCreditCardModalOpen(true);
return;
}

View File

@@ -1,16 +1,9 @@
import { fireEvent } from '@testing-library/react';
import logEvent from 'api/common/logEvent';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { render } from 'tests/test-utils';
import { BottomStripEvents, BottomStripSupportType } from '../../events';
import SupportButton from '../SupportButton';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('hooks/useChatSupport', () => ({
...jest.requireActual('hooks/useChatSupport'),
useChatSupport: jest.fn(),
@@ -44,9 +37,6 @@ describe('SupportButton', () => {
fireEvent.click(getByTestId(BUTTON));
expect(window.Pylon).toHaveBeenCalledWith('show');
expect(logEvent).toHaveBeenCalledWith(BottomStripEvents.SupportClicked, {
type: BottomStripSupportType.Pylon,
});
});
it('does not open the credit card modal', () => {
@@ -74,9 +64,6 @@ describe('SupportButton', () => {
expect(getByText(MODAL_TITLE)).toBeInTheDocument();
expect(window.Pylon).not.toHaveBeenCalled();
expect(logEvent).toHaveBeenCalledWith(BottomStripEvents.SupportClicked, {
type: BottomStripSupportType.AddCard,
});
});
});

View File

@@ -1,12 +0,0 @@
export enum BottomStripEvents {
SupportClicked = 'Bottom Strip: Support clicked',
}
export enum BottomStripEventKeys {
Type = 'type',
}
export enum BottomStripSupportType {
Pylon = 'pylon',
AddCard = 'add_card',
}

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

@@ -11,11 +11,9 @@ import {
SAVED_VIEW_LOAD_FAILED_NAME,
SAVED_VIEW_TOAST_POSITION,
} from './constants';
import { SavedViewCreatedFrom, SavedViewEvents } from './events';
import { useActiveSavedView } from './hooks/useActiveSavedView';
import { useSaveNewView } from './hooks/useSaveNewView';
import { useSavedViewActions } from './hooks/useSavedViewActions';
import { useSavedViewLogEvent } from './hooks/useSavedViewLogEvent';
import SaveChangesMenu from './SaveChangesMenu';
import SavedViewsIconButton from './SavedViewsIconButton';
import SaveViewModal from './SaveViewModal';
@@ -38,25 +36,9 @@ function SavedViewsHeader({
const { revertView, clearView, createView, updateView, isSaving } =
useSavedViewActions(source);
const logSavedViewEvent = useSavedViewLogEvent(source);
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
const handleClear = useCallback((): void => {
clearView();
logSavedViewEvent(SavedViewEvents.ViewCleared);
}, [clearView, logSavedViewEvent]);
const saveNewView = useSaveNewView(createView);
const handleSave = useCallback(
(displayName: string): Promise<boolean> =>
saveNewView(
displayName,
modalMode === 'saveAsNew'
? SavedViewCreatedFrom.SaveAsNew
: SavedViewCreatedFrom.Header,
),
[saveNewView, modalMode],
);
const handleSave = useSaveNewView(createView);
const handleUpdate = useCallback(async (): Promise<void> => {
if (view && (await updateView(view))) {
@@ -128,7 +110,7 @@ function SavedViewsHeader({
<SavedViewsIconButton
title="Clear view"
icon={<Undo2 size={14} />}
onClick={handleClear}
onClick={clearView}
testId="saved-views-clear"
/>
)}

View File

@@ -10,10 +10,8 @@ import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { SAVED_VIEWS_LIST_LOADING_ROWS } from './constants';
import DeleteSavedViewDialog from './DeleteSavedViewDialog';
import { SavedViewCreatedFrom, SavedViewEvents } from './events';
import { useSavedViewActions } from './hooks/useSavedViewActions';
import { useSavedViewHoverCard } from './hooks/useSavedViewHoverCard';
import { useSavedViewLogEvent } from './hooks/useSavedViewLogEvent';
import { useSavedViewRowActions } from './hooks/useSavedViewRowActions';
import { useSaveNewView } from './hooks/useSaveNewView';
import { useSavedViewsList } from './hooks/useSavedViewsList';
@@ -40,11 +38,6 @@ function SavedViewsList({
const { selectView, clearView, createView, isSaving } =
useSavedViewActions(source);
const saveNewView = useSaveNewView(createView);
const logSavedViewEvent = useSavedViewLogEvent(source);
const handleClear = useCallback((): void => {
clearView();
logSavedViewEvent(SavedViewEvents.ViewCleared);
}, [clearView, logSavedViewEvent]);
const { createdByMe, createdByOthers, isLoading, isError, refetch } =
useSavedViewsList(source, search);
const { copyViewLink, renameView, deleteView, isRenaming, isDeleting } =
@@ -138,9 +131,7 @@ function SavedViewsList({
<SavedViewsEmptyState
isSearching={search.trim().length > 0}
isSaving={isSaving}
onSave={(displayName): Promise<boolean> =>
saveNewView(displayName, SavedViewCreatedFrom.EmptyState)
}
onSave={saveNewView}
/>
);
}
@@ -158,7 +149,7 @@ function SavedViewsList({
views={createdByMe}
activeViewId={viewKey}
onSelect={selectView}
onClear={handleClear}
onClear={clearView}
onAction={handleAction}
hover={rowHover}
testId="saved-views-created-by-me"
@@ -170,7 +161,7 @@ function SavedViewsList({
views={createdByOthers}
activeViewId={viewKey}
onSelect={selectView}
onClear={handleClear}
onClear={clearView}
onAction={handleAction}
hover={rowHover}
testId="saved-views-created-by-others"

View File

@@ -1,268 +0,0 @@
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewCreatedFrom, SavedViewEvents } from '../events';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
explorerUrl,
makeView,
queryWith,
renderWithExplorerProviders,
viewUrl,
} from './savedViewsTestUtils';
const logEventMock = jest.fn();
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: (...args: unknown[]): void => logEventMock(...args),
}));
// copy-to-clipboard falls back to window.prompt in jsdom.
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: (): [unknown, jest.Mock] => [null, jest.fn()],
}));
const PATH = ROUTES.TRACES_EXPLORER;
const source = SavedviewtypesSourceDTO.traces;
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const slow = makeView({ id: 'view-2', displayName: 'Slow spans' });
function renderAll(
url = explorerUrl(PATH, {}),
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<>
<SavedViewsHeader source={source} onOpenViews={jest.fn()} />
<SavedViewsPanel source={source} onClose={jest.fn()} />
</>,
url,
);
}
const row = (name: string): HTMLElement =>
screen
.getAllByTestId('saved-views-row')
.find((element) => element.textContent?.includes(name)) as HTMLElement;
async function chooseFromRowMenu(
viewName: string,
item: string,
): Promise<void> {
await screen.findByTestId('saved-views-list');
await userEvent.click(
within(row(viewName)).getByTestId('saved-views-row-menu'),
);
await userEvent.click(await screen.findByRole('menuitem', { name: item }));
}
async function submitName(name: string): Promise<void> {
await userEvent.type(screen.getByTestId('save-view-name'), name);
await userEvent.click(screen.getByTestId('save-view-submit'));
}
async function waitForView(name: string): Promise<void> {
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(name),
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
);
}
const callsOf = (event: SavedViewEvents): unknown[][] =>
logEventMock.mock.calls.filter(([name]) => name === event);
describe('SavedViews analytics', () => {
beforeEach(() => {
localStorage.clear();
logEventMock.mockClear();
});
it('logs a view created from the header', async () => {
mockSavedViewsApi([errors], { createdId: 'view-new' });
renderAll();
await userEvent.click(screen.getByTestId('saved-views-create'));
await submitName('Slow checkout');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
source,
viewId: 'view-new',
from: SavedViewCreatedFrom.Header,
}),
);
});
it('logs a view created from the empty state', async () => {
mockSavedViewsApi([], { createdId: 'view-new' });
renderAll();
await userEvent.click(await screen.findByTestId('saved-views-list-create'));
await submitName('Slow checkout');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
source,
viewId: 'view-new',
from: SavedViewCreatedFrom.EmptyState,
}),
);
});
it('does not log a create that fails', async () => {
mockSavedViewsApi([errors], { createStatus: 500 });
renderAll();
await userEvent.click(screen.getByTestId('saved-views-create'));
await submitName('Slow checkout');
await act(() => new Promise((resolve) => setTimeout(resolve, 100)));
expect(callsOf(SavedViewEvents.ViewCreated)).toHaveLength(0);
});
describe('on a changed view', () => {
async function openDirtyView(): Promise<void> {
const { history } = renderAll(viewUrl(PATH, errors));
await waitForView('Errors');
act(() => {
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
);
});
await screen.findByTestId('saved-views-discard');
}
it('logs an update', async () => {
mockSavedViewsApi([errors]);
await openDirtyView();
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
await userEvent.click(
await screen.findByRole('menuitem', { name: 'Update selected view' }),
);
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewUpdated, {
source,
viewId: errors.id,
}),
);
});
it('logs a view created with save as new', async () => {
mockSavedViewsApi([errors], { createdId: 'view-copy' });
await openDirtyView();
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
await userEvent.click(
await screen.findByRole('menuitem', { name: 'Save as new view' }),
);
await submitName('Errors copy');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
source,
viewId: 'view-copy',
from: SavedViewCreatedFrom.SaveAsNew,
}),
);
});
it('logs discarded changes', async () => {
mockSavedViewsApi([errors]);
await openDirtyView();
logEventMock.mockClear();
await userEvent.click(screen.getByTestId('saved-views-discard'));
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ChangesDiscarded, {
source,
viewId: errors.id,
});
});
});
it('logs a cleared view', async () => {
mockSavedViewsApi([errors]);
renderAll(viewUrl(PATH, errors));
await waitForView('Errors');
await userEvent.click(screen.getByTestId('saved-views-clear'));
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCleared, {
source,
});
});
it('logs a view cleared from its row', async () => {
mockSavedViewsApi([errors]);
renderAll(viewUrl(PATH, errors));
await waitForView('Errors');
await userEvent.click(await screen.findByTestId('saved-views-row-clear'));
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCleared, {
source,
});
});
it('logs only the delete when the open view is deleted', async () => {
mockSavedViewsApi([errors]);
renderAll(viewUrl(PATH, errors));
await waitForView('Errors');
await chooseFromRowMenu('Errors', 'Delete');
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewDeleted, {
source,
viewId: errors.id,
}),
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('My view'),
);
expect(callsOf(SavedViewEvents.ViewCleared)).toHaveLength(0);
});
it('logs the row menu actions', async () => {
mockSavedViewsApi([errors, slow]);
renderAll();
await chooseFromRowMenu('Errors', 'Copy link');
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.LinkCopied, {
source,
viewId: errors.id,
});
await chooseFromRowMenu('Errors', 'Edit details');
await submitName(' renamed');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(
SavedViewEvents.ViewDetailsEdited,
{ source, viewId: errors.id },
),
);
await chooseFromRowMenu('Slow spans', 'Delete');
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewDeleted, {
source,
viewId: slow.id,
}),
);
});
});

View File

@@ -1,23 +0,0 @@
export enum SavedViewEvents {
ViewCreated = 'Saved Views: View created',
ViewUpdated = 'Saved Views: View updated',
ViewDetailsEdited = 'Saved Views: View details edited',
ViewDeleted = 'Saved Views: View deleted',
LinkCopied = 'Saved Views: Link copied',
ViewCleared = 'Saved Views: View cleared',
ChangesDiscarded = 'Saved Views: Changes discarded',
}
export enum SavedViewEventKeys {
// Injected on every event by useSavedViewLogEvent
Source = 'source',
ViewId = 'viewId',
From = 'from',
}
export enum SavedViewCreatedFrom {
Header = 'header',
SaveAsNew = 'saveAsNew',
EmptyState = 'emptyState',
}

View File

@@ -2,15 +2,14 @@ import { useCallback } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { SAVED_VIEW_TOAST_POSITION } from '../constants';
import { SavedViewCreatedFrom } from '../events';
import { UseSavedViewActionsResult } from '../types';
export function useSaveNewView(
createView: UseSavedViewActionsResult['createView'],
): (displayName: string, from: SavedViewCreatedFrom) => Promise<boolean> {
): (displayName: string) => Promise<boolean> {
return useCallback(
async (displayName: string, from: SavedViewCreatedFrom): Promise<boolean> => {
if (!(await createView(displayName, from))) {
async (displayName: string): Promise<boolean> => {
if (!(await createView(displayName))) {
return false;
}
toast.success('View created', {

View File

@@ -30,18 +30,12 @@ import {
SAVED_VIEW_TOAST_POSITION,
SAVED_VIEW_URL_PARAMS,
} from '../constants';
import {
SavedViewCreatedFrom,
SavedViewEventKeys,
SavedViewEvents,
} from '../events';
import { UseSavedViewActionsResult } from '../types';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { getViewColumnsAndFormatting } from '../utils/getViewColumnsAndFormatting';
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
import { useLastUsedView } from './useLastUsedView';
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
// Columns and formatting need no handling here: with a viewKey in the url the
// preference provider reads them from the view, and without one it falls back
@@ -56,7 +50,6 @@ export function useSavedViewActions(
useQueryBuilder();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { setLastUsedView, clearLastUsedView } = useLastUsedView(source);
const logSavedViewEvent = useSavedViewLogEvent(source);
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const { options } = useOptionsMenu({
@@ -114,11 +107,8 @@ export function useSavedViewActions(
}
}
selectView(view);
logSavedViewEvent(SavedViewEvents.ChangesDiscarded, {
[SavedViewEventKeys.ViewId]: view.id,
});
},
[optionsDataSource, preferencesSlice, selectView, logSavedViewEvent],
[optionsDataSource, preferencesSlice, selectView],
);
// The query builder redirects only ever add params, so the url is rebuilt
@@ -132,10 +122,7 @@ export function useSavedViewActions(
}, [clearLastUsedView, pathname, safeNavigate]);
const createView = useCallback(
async (
displayName: string,
from: SavedViewCreatedFrom,
): Promise<string | undefined> => {
async (displayName: string): Promise<string | undefined> => {
try {
const { data } = await createSavedView({
data: {
@@ -150,10 +137,6 @@ export function useSavedViewActions(
[QueryParams.viewKey]: data.id,
});
setLastUsedView(data.id, displayName);
logSavedViewEvent(SavedViewEvents.ViewCreated, {
[SavedViewEventKeys.ViewId]: data.id,
[SavedViewEventKeys.From]: from,
});
return data.id;
} catch (error) {
toast.error(
@@ -175,7 +158,6 @@ export function useSavedViewActions(
stagedQuery,
currentQuery,
setLastUsedView,
logSavedViewEvent,
],
);
@@ -191,9 +173,6 @@ export function useSavedViewActions(
},
});
await invalidateViews(view.id);
logSavedViewEvent(SavedViewEvents.ViewUpdated, {
[SavedViewEventKeys.ViewId]: view.id,
});
return true;
} catch (error) {
toast.error(
@@ -206,7 +185,7 @@ export function useSavedViewActions(
return false;
}
},
[updateSavedView, source, toCurrentSpec, invalidateViews, logSavedViewEvent],
[updateSavedView, source, toCurrentSpec, invalidateViews],
);
return {

View File

@@ -1,31 +0,0 @@
import { useCallback, useRef } from 'react';
import logEvent from 'api/common/logEvent';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { SavedViewEventKeys, SavedViewEvents } from '../events';
export type SavedViewLogEvent = (
event: SavedViewEvents,
attributes?: Record<string, unknown>,
) => void;
export function useSavedViewLogEvent(
source: SavedviewtypesSourceDTO,
): SavedViewLogEvent {
const sourceRef = useRef(source);
sourceRef.current = source;
return useCallback(
(event: SavedViewEvents, attributes: Record<string, unknown> = {}): void => {
try {
void logEvent(event, {
[SavedViewEventKeys.Source]: sourceRef.current,
...attributes,
});
} catch {
// No-op.
}
},
[],
);
}

View File

@@ -22,11 +22,9 @@ import {
SAVED_VIEW_SCHEMA_VERSION,
SAVED_VIEW_TOAST_POSITION,
} from '../constants';
import { SavedViewEventKeys, SavedViewEvents } from '../events';
import { UseSavedViewRowActionsResult } from '../types';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
function showErrorToast(error: unknown, fallback: string): void {
toast.error(
@@ -44,7 +42,6 @@ export function useSavedViewRowActions(
const queryClient = useQueryClient();
const { pathname } = useLocation();
const [, copyToClipboard] = useCopyToClipboard();
const logSavedViewEvent = useSavedViewLogEvent(source);
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
useUpdateSavedView();
const { mutateAsync: deleteSavedView, isLoading: isDeleting } =
@@ -58,11 +55,8 @@ export function useSavedViewRowActions(
});
copyToClipboard(getAbsoluteUrl(path));
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.LinkCopied, {
[SavedViewEventKeys.ViewId]: view.id,
});
},
[pathname, copyToClipboard, logSavedViewEvent],
[pathname, copyToClipboard],
);
const renameView = useCallback(
@@ -81,16 +75,13 @@ export function useSavedViewRowActions(
});
await invalidateSavedViews(queryClient, source, view.id);
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.ViewDetailsEdited, {
[SavedViewEventKeys.ViewId]: view.id,
});
return true;
} catch (error) {
showErrorToast(error, 'Could not update the view');
return false;
}
},
[updateSavedView, source, queryClient, logSavedViewEvent],
[updateSavedView, source, queryClient],
);
// The deleted view's own query is left alone: refetching it would fail
@@ -101,16 +92,13 @@ export function useSavedViewRowActions(
await deleteSavedView({ pathParams: { id: view.id } });
await invalidateSavedViews(queryClient, source);
toast.success('View deleted', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.ViewDeleted, {
[SavedViewEventKeys.ViewId]: view.id,
});
return true;
} catch (error) {
showErrorToast(error, 'Could not delete the view');
return false;
}
},
[deleteSavedView, source, queryClient, logSavedViewEvent],
[deleteSavedView, source, queryClient],
);
return { copyViewLink, renameView, deleteView, isRenaming, isDeleting };

View File

@@ -9,8 +9,6 @@ import { OptionsQuery } from 'container/OptionsMenu/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewCreatedFrom } from './events';
export type SavedViewSourcePage = DataSource | 'meter';
export type SavedViewOptions = Pick<
@@ -50,10 +48,7 @@ export interface UseSavedViewActionsResult {
selectView: (view: SavedviewtypesSavedViewDTO) => void;
revertView: (view: SavedviewtypesSavedViewDTO) => void;
clearView: () => void;
createView: (
displayName: string,
from: SavedViewCreatedFrom,
) => Promise<string | undefined>;
createView: (displayName: string) => Promise<string | undefined>;
updateView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
isSaving: boolean;
}

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,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

@@ -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

@@ -0,0 +1,29 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { getSpanDisplayData } from '../utils';
describe('getSpanDisplayData', () => {
it('keeps only allow-listed fields', () => {
const span = {
span_id: 's1',
trace_id: 't1',
name: 'GET /api',
attributes: { 'http.method': 'GET' },
resource: { 'service.name': 'api' },
level: 1,
has_children: true,
sub_tree_node_count: 3,
'service.name': 'api',
time_unix: 1700000000,
formattedInput: '[]',
} as unknown as SpanV3;
expect(getSpanDisplayData(span)).toStrictEqual({
span_id: 's1',
trace_id: 't1',
name: 'GET /api',
attributes: { 'http.method': 'GET' },
resource: { 'service.name': 'api' },
});
});
});

View File

@@ -1,3 +1,5 @@
import type { SpantypesWaterfallSpanDTO } from 'api/generated/services/sigNoz.schemas';
import { pick } from 'lodash-es';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { COLOR_BY_OPTIONS } from './constants';
@@ -46,28 +48,43 @@ export function hasInfraMetadata(span: SpanV3 | undefined): boolean {
return INFRA_METADATA_KEYS.some((key) => getSpanAttribute(span, key));
}
// Top-level fields that exist on the API response only to support waterfall
// rendering. They have no value in the Span Details DataViewer. Drop the whole
// constant + helper once the backend stops emitting them.
const HIDDEN_SPAN_FIELDS_IN_DETAILS_VIEW: ReadonlySet<string> = new Set([
'sub_tree_node_count',
'has_children',
'level',
'service.name',
]);
// Top-level fields shown in the Span Details DataViewer. Everything else on the
// API response (e.g. waterfall rendering data) is hidden.
const VISIBLE_SPAN_FIELDS_IN_DETAILS_VIEW = [
'span_id',
'trace_id',
'parent_span_id',
'name',
'kind_string',
'duration_nano',
'has_error',
'status_code',
'status_code_string',
'status_message',
'flags',
'is_remote',
'trace_state',
'attributes',
'resource',
'events',
'references',
'http_method',
'http_url',
'http_host',
'db_name',
'db_operation',
'external_http_method',
'external_http_url',
'response_status_code',
// Keys must exist on the waterfall DTO.
] as const satisfies readonly (keyof SpantypesWaterfallSpanDTO)[];
/**
* Shallow-copies the span with waterfall-only fields stripped, for display in
* the Span Details DataViewer.
* Shallow-copies the allow-listed span fields, for display in the Span Details
* DataViewer.
*/
export function getSpanDisplayData(span: SpanV3): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(span)) {
if (!HIDDEN_SPAN_FIELDS_IN_DETAILS_VIEW.has(key)) {
result[key] = value;
}
}
return result;
return pick(span, VISIBLE_SPAN_FIELDS_IN_DETAILS_VIEW);
}
/**

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

@@ -71,6 +71,20 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/rules/{id}/clone", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.CloneRuleByID), handler.OpenAPIDef{
ID: "CloneRuleByID",
Tags: []string{"rules"},
Summary: "Clone alert rule",
Description: "This endpoint creates a new alert rule from the stored definition of an existing one. The clone keeps every modeled field of the source, including its enabled/disabled state, and gets the source's name suffixed with \" - Copy\" (or a bumped \" - Copy (n)\" counter when the source is itself a copy). The clone is owned by the caller.",
Response: new(ruletypes.Rule),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/rules/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.UpdateRuleByID), handler.OpenAPIDef{
ID: "UpdateRuleByID",
Tags: []string{"rules"},

View File

@@ -159,7 +159,7 @@ func NewBaseRule(
id: id,
orgID: orgID,
externalURL: externalURL,
name: p.AlertName,
name: p.AlertName.StringValue(),
typ: p.AlertType,
ruleCondition: p.RuleCondition,
evalWindow: p.EvalWindow,

View File

@@ -126,7 +126,7 @@ func mergeFirstSeenMaps(maps ...map[telemetrytypes.MetricMetadataLookupKey]int64
// createPostableRule creates a PostableRule with the given CompositeQuery
func createPostableRule(compositeQuery *ruletypes.AlertCompositeQuery) ruletypes.PostableRule {
return ruletypes.PostableRule{
AlertName: "Test Rule",
AlertName: valuer.NewDisplayName("Test Rule"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{

View File

@@ -546,6 +546,24 @@ func (m *Manager) deleteTask(taskName string) {
// CreateRule stores rule def into db and also
// starts an executor for the rule, unless the rule is disabled
func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error) {
parsedRule := ruletypes.PostableRule{}
if err := json.Unmarshal([]byte(ruleStr), &parsedRule); err != nil {
return nil, err
}
return m.createRule(ctx, parsedRule, ruleStr)
}
func (m *Manager) CreatePostableRule(ctx context.Context, rule *ruletypes.PostableRule) (*ruletypes.GettableRule, error) {
data, err := json.Marshal(rule)
if err != nil {
return nil, err
}
return m.createRule(ctx, *rule, string(data))
}
func (m *Manager) createRule(ctx context.Context, parsedRule ruletypes.PostableRule, data string) (*ruletypes.GettableRule, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, err
@@ -555,11 +573,6 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
if err != nil {
return nil, err
}
parsedRule := ruletypes.PostableRule{}
err = json.Unmarshal([]byte(ruleStr), &parsedRule)
if err != nil {
return nil, err
}
if err := parsedRule.Validate(); err != nil {
return nil, err
}
@@ -579,7 +592,7 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
CreatedBy: claims.Email,
UpdatedBy: claims.Email,
},
Data: ruleStr,
Data: data,
OrgID: claims.OrgID,
}
@@ -1106,7 +1119,7 @@ func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleS
parsedRule.NotificationSettings.GroupBy = append(parsedRule.NotificationSettings.GroupBy, ruletypes.LabelThresholdName)
}
config := parsedRule.NotificationSettings.GetAlertManagerNotificationConfig()
err = m.alertmanager.SetNotificationConfig(ctx, orgID, parsedRule.AlertName, &config)
err = m.alertmanager.SetNotificationConfig(ctx, orgID, parsedRule.AlertName.StringValue(), &config)
if err != nil {
return 0, err
}

View File

@@ -36,7 +36,7 @@ type PromRuleTestCase struct {
// ThresholdRuleAtLeastOnceValueAbove creates a PostableRule for threshold rule test notifications
func ThresholdRuleAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes.PostableRule {
return ruletypes.PostableRule{
AlertName: "test-alert",
AlertName: valuer.NewDisplayName("test-alert"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -95,7 +95,7 @@ func ThresholdRuleAtLeastOnceValueAbove(target float64, recovery *float64) rulet
// BuildPromAtLeastOnceValueAbove creates a PostableRule for PromQL rule test notifications
func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes.PostableRule {
return ruletypes.PostableRule{
AlertName: "test-prom-alert",
AlertName: valuer.NewDisplayName("test-prom-alert"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeProm,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{

View File

@@ -359,7 +359,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
func (r *PromRule) String() string {
ar := ruletypes.PostableRule{
AlertName: r.name,
AlertName: valuer.NewDisplayName(r.name),
RuleCondition: r.ruleCondition,
EvalWindow: r.evalWindow,
Labels: r.labels.Map(),

View File

@@ -35,7 +35,7 @@ func getVectorValues(vectors []ruletypes.Sample) []float64 {
func TestPromRuleEval(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Test Rule",
AlertName: valuer.NewDisplayName("Test Rule"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeProm,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -746,7 +746,7 @@ func TestPromRuleUnitCombinations(t *testing.T) {
evalTime := baseTime.Add(5 * time.Minute)
postableRule := ruletypes.PostableRule{
AlertName: "Units test",
AlertName: valuer.NewDisplayName("Units test"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeProm,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -914,7 +914,7 @@ func TestPromRuleUnitCombinations(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: postableRule.AlertName,
Name: postableRule.AlertName.StringValue(),
TargetValue: &c.target,
TargetUnit: c.targetUnit,
MatchType: c.matchType,
@@ -962,7 +962,7 @@ func TestPromRuleNoData(t *testing.T) {
evalTime := baseTime.Add(5 * time.Minute)
postableRule := ruletypes.PostableRule{
AlertName: "No data test",
AlertName: valuer.NewDisplayName("No data test"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeProm,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -1019,7 +1019,7 @@ func TestPromRuleNoData(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: postableRule.AlertName,
Name: postableRule.AlertName.StringValue(),
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsEq,
@@ -1062,7 +1062,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
evalTime := baseTime.Add(5 * time.Minute)
postableRule := ruletypes.PostableRule{
AlertName: "Multiple threshold test",
AlertName: valuer.NewDisplayName("Multiple threshold test"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeProm,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -1261,7 +1261,7 @@ func TestPromRule_NoData(t *testing.T) {
evalTime := time.Now()
postableRule := ruletypes.PostableRule{
AlertName: "Test no data",
AlertName: valuer.NewDisplayName("Test no data"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeProm,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -1357,7 +1357,7 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
target := 500.0
postableRule := ruletypes.PostableRule{
AlertName: "Test no data with AbsentFor",
AlertName: valuer.NewDisplayName("Test no data with AbsentFor"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeProm,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -1483,7 +1483,7 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
lookBackDelta := time.Minute
postableRule := ruletypes.PostableRule{
AlertName: "Unit test",
AlertName: valuer.NewDisplayName("Unit test"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeProm,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -1572,7 +1572,7 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: postableRule.AlertName,
Name: postableRule.AlertName.StringValue(),
TargetValue: rc.Target,
MatchType: rc.MatchType,
CompareOperator: rc.CompareOperator,

View File

@@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// TestNotification prepares a dummy rule for given rule parameters and
@@ -23,7 +24,7 @@ func defaultTestNotification(opts PrepareTestRuleOptions) (int, error) {
}
parsedRule := opts.Rule
var alertname = parsedRule.AlertName
var alertname = parsedRule.AlertName.StringValue()
if alertname == "" {
// alertname is not mandatory for testing, so picking
// a random string here
@@ -31,7 +32,7 @@ func defaultTestNotification(opts PrepareTestRuleOptions) (int, error) {
}
// append name to indicate this is test alert
parsedRule.AlertName = fmt.Sprintf("%s%s", alertname, ruletypes.TestAlertPostFix)
parsedRule.AlertName = valuer.NewDisplayName(fmt.Sprintf("%s%s", alertname, ruletypes.TestAlertPostFix))
var rule Rule
var err error

View File

@@ -468,7 +468,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
func (r *ThresholdRule) String() string {
ar := ruletypes.PostableRule{
AlertName: r.name,
AlertName: valuer.NewDisplayName(r.name),
RuleCondition: r.ruleCondition,
EvalWindow: r.evalWindow,
Labels: r.labels.Map(),

View File

@@ -26,7 +26,7 @@ import (
func TestThresholdRuleEvalWithoutRecoveryTarget(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Eval test without recovery target",
AlertName: valuer.NewDisplayName("Eval test without recovery target"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -145,7 +145,7 @@ func TestNormalizeLabelName(t *testing.T) {
func TestPrepareParamsForLogs(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Tricky Condition Tests",
AlertName: valuer.NewDisplayName("Tricky Condition Tests"),
AlertType: ruletypes.AlertTypeLogs,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -210,7 +210,7 @@ func TestPrepareParamsForLogs(t *testing.T) {
func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Tricky Condition Tests",
AlertName: valuer.NewDisplayName("Tricky Condition Tests"),
AlertType: ruletypes.AlertTypeLogs,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -275,7 +275,7 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Tricky Condition Tests",
AlertName: valuer.NewDisplayName("Tricky Condition Tests"),
AlertType: ruletypes.AlertTypeTraces,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -340,7 +340,7 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
func TestPrepareParamsForTraces(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Links to traces test",
AlertName: valuer.NewDisplayName("Links to traces test"),
AlertType: ruletypes.AlertTypeTraces,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -406,7 +406,7 @@ func TestPrepareParamsForTraces(t *testing.T) {
func TestThresholdRuleLabelNormalization(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Tricky Condition Tests",
AlertName: valuer.NewDisplayName("Tricky Condition Tests"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -511,7 +511,7 @@ func TestThresholdRuleLabelNormalization(t *testing.T) {
func TestThresholdRuleUnitCombinations(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Units test",
AlertName: valuer.NewDisplayName("Units test"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -662,7 +662,7 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: postableRule.AlertName,
Name: postableRule.AlertName.StringValue(),
TargetValue: &c.target,
TargetUnit: c.targetUnit,
MatchType: c.matchType,
@@ -704,7 +704,7 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
func TestThresholdRuleNoData(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "No data test",
AlertName: valuer.NewDisplayName("No data test"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -771,7 +771,7 @@ func TestThresholdRuleNoData(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: postableRule.AlertName,
Name: postableRule.AlertName.StringValue(),
TargetValue: &target,
MatchType: ruletypes.AtleastOnce,
CompareOperator: ruletypes.ValueIsEq,
@@ -808,7 +808,7 @@ func TestThresholdRuleNoData(t *testing.T) {
func TestThresholdRuleTracesLink(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Traces link test",
AlertName: valuer.NewDisplayName("Traces link test"),
AlertType: ruletypes.AlertTypeTraces,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -876,7 +876,7 @@ func TestThresholdRuleTracesLink(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: postableRule.AlertName,
Name: postableRule.AlertName.StringValue(),
TargetValue: &c.target,
TargetUnit: c.targetUnit,
MatchType: c.matchType,
@@ -918,7 +918,7 @@ func TestThresholdRuleTracesLink(t *testing.T) {
func TestThresholdRuleAITracesLink(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "AI traces link test",
AlertName: valuer.NewDisplayName("AI traces link test"),
AlertType: ruletypes.AlertTypeAITraces,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -984,7 +984,7 @@ func TestThresholdRuleAITracesLink(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: postableRule.AlertName,
Name: postableRule.AlertName.StringValue(),
TargetValue: &c.target,
TargetUnit: c.targetUnit,
MatchType: c.matchType,
@@ -1016,7 +1016,7 @@ func TestThresholdRuleAITracesLink(t *testing.T) {
func TestThresholdRuleLogsLink(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Logs link test",
AlertName: valuer.NewDisplayName("Logs link test"),
AlertType: ruletypes.AlertTypeLogs,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -1092,7 +1092,7 @@ func TestThresholdRuleLogsLink(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: postableRule.AlertName,
Name: postableRule.AlertName.StringValue(),
TargetValue: &c.target,
TargetUnit: c.targetUnit,
MatchType: c.matchType,
@@ -1134,7 +1134,7 @@ func TestThresholdRuleLogsLink(t *testing.T) {
func TestMultipleThresholdRule(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Multiple threshold test",
AlertName: valuer.NewDisplayName("Multiple threshold test"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -1315,7 +1315,7 @@ func TestMultipleThresholdRule(t *testing.T) {
func TestThresholdRuleEval_BasicCases(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Eval Recovery Threshold Test",
AlertName: valuer.NewDisplayName("Eval Recovery Threshold Test"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -1349,7 +1349,7 @@ func TestThresholdRuleEval_BasicCases(t *testing.T) {
func TestThresholdRuleEval_MatchPlusCompareOps(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Eval Match Plus Compare Ops Threshold Test",
AlertName: valuer.NewDisplayName("Eval Match Plus Compare Ops Threshold Test"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -1389,7 +1389,7 @@ func TestThresholdRuleEval_SendUnmatchedBypassesRecovery(t *testing.T) {
recovery := 4.0
postableRule := ruletypes.PostableRule{
AlertName: "Send unmatched bypass recovery",
AlertName: valuer.NewDisplayName("Send unmatched bypass recovery"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -1476,7 +1476,7 @@ func TestThresholdRuleEval_SendUnmatchedVariants(t *testing.T) {
target := 10.0
recovery := 5.0
postableRule := ruletypes.PostableRule{
AlertName: "Send unmatched variants",
AlertName: valuer.NewDisplayName("Send unmatched variants"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -1588,7 +1588,7 @@ func TestThresholdRuleEval_RecoveryNotMetSendUnmatchedFalse(t *testing.T) {
now := time.Now()
postableRule := ruletypes.PostableRule{
AlertName: "Recovery not met sendUnmatched false",
AlertName: valuer.NewDisplayName("Recovery not met sendUnmatched false"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -1888,7 +1888,7 @@ func runMultiThresholdEvalTests(t *testing.T, postableRule ruletypes.PostableRul
// where each threshold can be in a different state (firing, recovering, resolved)
func TestThresholdRuleEval_MultiThreshold(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Multi-Threshold Recovery Test",
AlertName: valuer.NewDisplayName("Multi-Threshold Recovery Test"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -1922,7 +1922,7 @@ func TestThresholdRuleEval_MultiThreshold(t *testing.T) {
func TestThresholdEval_RequireMinPoints(t *testing.T) {
postableRule := ruletypes.PostableRule{
AlertName: "Unit test",
AlertName: valuer.NewDisplayName("Unit test"),
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
@@ -2057,7 +2057,7 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: postableRule.AlertName,
Name: postableRule.AlertName.StringValue(),
TargetValue: &c.target,
MatchType: rc.MatchType,
CompareOperator: rc.CompareOperator,

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

@@ -7,6 +7,7 @@ type Handler interface {
ListRulesV3(http.ResponseWriter, *http.Request)
GetRuleByID(http.ResponseWriter, *http.Request)
CreateRule(http.ResponseWriter, *http.Request)
CloneRuleByID(http.ResponseWriter, *http.Request)
UpdateRuleByID(http.ResponseWriter, *http.Request)
DeleteRuleByID(http.ResponseWriter, *http.Request)
PatchRuleByID(http.ResponseWriter, *http.Request)

View File

@@ -29,6 +29,9 @@ type Ruler interface {
// model to store structured data.
CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error)
// CloneRule creates a new rule from the stored definition of id, with a " - Copy" suffixed name.
CloneRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error)
// EditRule replaces the rule identified by id with the given JSON string.
// TODO: same as CreateRule — accept PostableRule instead of raw string.
EditRule(ctx context.Context, ruleStr string, id valuer.UUID) error

View File

@@ -235,7 +235,7 @@ func (r *rule) GetStoredRulesByMetricName(ctx context.Context, orgID string, met
if found && !seen[storedRule.ID.StringValue()] {
seen[storedRule.ID.StringValue()] = true
alerts = append(alerts, ruletypes.RuleAlert{
AlertName: ruleData.AlertName,
AlertName: ruleData.AlertName.StringValue(),
AlertID: storedRule.ID.StringValue(),
})
}

View File

@@ -105,6 +105,25 @@ func (handler *handler) CreateRule(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusCreated, ruletypes.NewRule(rule))
}
func (handler *handler) CloneRuleByID(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
rule, err := handler.ruler.CloneRule(ctx, id)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, ruletypes.NewRule(rule))
}
func (handler *handler) UpdateRuleByID(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()

View File

@@ -18,6 +18,7 @@ import (
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -128,6 +129,30 @@ func (provider *provider) CreateRule(ctx context.Context, ruleStr string) (*rule
return provider.manager.CreateRule(ctx, ruleStr)
}
func (provider *provider) CloneRule(ctx context.Context, id valuer.UUID) (*ruletypes.GettableRule, error) {
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
return nil, err
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
return nil, err
}
storedRule, err := provider.ruleStore.GetStoredRule(ctx, orgID, id)
if err != nil {
return nil, err
}
postableRule, err := storedRule.ToPostableRuleForCloning()
if err != nil {
return nil, err
}
return provider.manager.CreatePostableRule(ctx, postableRule)
}
func (provider *provider) EditRule(ctx context.Context, ruleStr string, id valuer.UUID) error {
return provider.manager.EditRule(ctx, ruleStr, id)
}

View File

@@ -60,7 +60,7 @@ const (
// PostableRule is used to create alerting rule from HTTP api.
type PostableRule struct {
AlertName string `json:"alert" required:"true"`
AlertName valuer.DisplayName `json:"alert" required:"true"`
AlertType AlertType `json:"alertType" required:"true"`
Description string `json:"description,omitempty"`
RuleType RuleType `json:"ruleType" required:"true"`
@@ -403,7 +403,7 @@ func (r *PostableRule) validate() error {
func (r *PostableRule) Validate() error {
var errs []error
if r.AlertName == "" {
if r.AlertName.IsZero() {
errs = append(errs, errors.NewInvalidInputf(errors.CodeInvalidInput, "alert: field is required"))
}
@@ -585,7 +585,7 @@ func (r *PostableRule) validateV2Alpha1() []error {
}
func testTemplateParsing(rl *PostableRule) (errs []error) {
if rl.AlertName == "" {
if rl.AlertName.IsZero() {
// Not an alerting rule.
return errs
}
@@ -597,7 +597,7 @@ func testTemplateParsing(rl *PostableRule) (errs []error) {
tmpl := NewTemplateExpander(
context.TODO(),
defs+text,
"__alert_"+rl.AlertName,
"__alert_"+rl.AlertName.StringValue(),
tmplData,
nil,
)

View File

@@ -54,7 +54,7 @@ func TestParseIntoRule(t *testing.T) {
kind: RuleDataKindJson,
expectError: false,
validate: func(t *testing.T, rule *PostableRule) {
if rule.AlertName != "TestAlert" {
if rule.AlertName.StringValue() != "TestAlert" {
t.Errorf("Expected alert name 'TestAlert', got '%s'", rule.AlertName)
}
if rule.RuleType != RuleTypeThreshold {

View File

@@ -48,6 +48,15 @@ func NewStatsFromRules(rules []*StorableRule) map[string]any {
return stats
}
func (rule *StorableRule) ToPostableRuleForCloning() (*PostableRule, error) {
postable := &PostableRule{}
if err := json.Unmarshal([]byte(rule.Data), postable); err != nil {
return nil, err
}
postable.AlertName = postable.AlertName.GenerateCloneName()
return postable, nil
}
// RuleAlert represents an alert associated with a rule, used when filtering by metric name.
type RuleAlert struct {
AlertName string

110
pkg/valuer/display_name.go Normal file
View File

@@ -0,0 +1,110 @@
package valuer
import (
"database/sql/driver"
"encoding/json"
"fmt"
"reflect"
"regexp"
"strconv"
"github.com/SigNoz/signoz/pkg/errors"
)
var _ Valuer = (*DisplayName)(nil)
var cloneCopySuffixRegex = regexp.MustCompile(`^(.*) - Copy(?: \((\d+)\))?$`)
// DisplayName is a free-text, user-facing name; unlike String it is stored exactly as given.
type DisplayName struct {
val string
}
func NewDisplayName(val string) DisplayName {
return DisplayName{val: val}
}
func (name DisplayName) IsZero() bool {
return name.val == ""
}
func (name DisplayName) StringValue() string {
return name.val
}
func (name DisplayName) String() string {
return name.val
}
// GenerateCloneName appends " - Copy" or bumps an existing " - Copy (n)" counter.
func (name DisplayName) GenerateCloneName() DisplayName {
base, count := name.val, 0
if m := cloneCopySuffixRegex.FindStringSubmatch(name.val); m != nil {
base = m[1]
count = 1 // bare " - Copy"
if m[2] != "" {
count, _ = strconv.Atoi(m[2])
}
}
if count++; count > 1 {
return NewDisplayName(fmt.Sprintf("%s - Copy (%d)", base, count))
}
return NewDisplayName(fmt.Sprintf("%s - Copy", base))
}
func (name DisplayName) MarshalJSON() ([]byte, error) {
return json.Marshal(name.val)
}
// UnmarshalJSON treats null as a no-op, matching how a plain string field decodes.
func (name *DisplayName) UnmarshalJSON(data []byte) error {
if string(data) == "null" {
return nil
}
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
*name = NewDisplayName(str)
return nil
}
func (name DisplayName) Value() (driver.Value, error) {
return name.val, nil
}
func (name *DisplayName) Scan(val any) error {
if name == nil {
return errors.Newf(errors.TypeInternal, ErrCodeUnknownValuerScan, "display name: (nil \"%s\")", reflect.TypeOf(name).String())
}
if val == nil {
*name = NewDisplayName("")
return nil
}
str, ok := val.(string)
if !ok {
return errors.Newf(errors.TypeInternal, ErrCodeUnknownValuerScan, "display name: (non-string \"%s\")", reflect.TypeOf(val).String())
}
*name = NewDisplayName(str)
return nil
}
func (name *DisplayName) UnmarshalText(text []byte) error {
*name = NewDisplayName(string(text))
return nil
}
func (name DisplayName) MarshalText() ([]byte, error) {
return []byte(name.val), nil
}
func (name *DisplayName) UnmarshalParam(param string) error {
*name = NewDisplayName(param)
return nil
}

View File

@@ -0,0 +1,112 @@
package valuer
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDisplayName_GenerateCloneName(t *testing.T) {
testCases := []struct {
name string
input string
expected string
}{
{name: "PlainName_CopySuffixAppended", input: "High CPU", expected: "High CPU - Copy"},
{name: "CopySuffix_BumpedToTwo", input: "High CPU - Copy", expected: "High CPU - Copy (2)"},
{name: "NumberedSuffix_Incremented", input: "High CPU - Copy (2)", expected: "High CPU - Copy (3)"},
{name: "MultiDigitSuffix_Incremented", input: "svc - Copy (41)", expected: "svc - Copy (42)"},
{name: "ContainsCopyWord_NotTreatedAsSuffix", input: "Copy of things", expected: "Copy of things - Copy"},
{name: "RepeatedCopy_OnlyTrailingMarkerParsed", input: "Prod - Copy - Copy", expected: "Prod - Copy - Copy (2)"},
{name: "EmptyName_SuffixOnly", input: "", expected: " - Copy"},
{
name: "LongName_NotTruncated",
input: "Muting And Manual Resolving Log Based Alert 7 October With A Very Long Descriptive Name That Goes On And On Past One Hundred Twenty Eight Characters",
expected: "Muting And Manual Resolving Log Based Alert 7 October With A Very Long Descriptive Name That Goes On And On Past One Hundred Twenty Eight Characters - Copy",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expected, NewDisplayName(testCase.input).GenerateCloneName().StringValue())
})
}
}
func TestDisplayName_UnmarshalJSON(t *testing.T) {
testCases := []struct {
name string
existing string
input string
expected string
expectErr bool
}{
{name: "MixedCaseAndWhitespace_Preserved", input: `" High CPU Mixed "`, expected: " High CPU Mixed "},
{name: "Null_KeepsExistingValue", existing: "kept", input: `null`, expected: "kept"},
{name: "NonString_Error", input: `42`, expectErr: true},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
name := NewDisplayName(testCase.existing)
err := json.Unmarshal([]byte(testCase.input), &name)
if testCase.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, testCase.expected, name.StringValue())
})
}
}
func TestDisplayName_MarshalJSON(t *testing.T) {
out, err := json.Marshal(struct {
Name DisplayName `json:"name"`
}{Name: NewDisplayName(" High CPU ")})
require.NoError(t, err)
assert.JSONEq(t, `{"name": " High CPU "}`, string(out))
}
func TestDisplayName_Scan(t *testing.T) {
testCases := []struct {
name string
input any
expected string
expectErr bool
}{
{name: "String_Scanned", input: "High CPU", expected: "High CPU"},
{name: "Nil_ScannedAsZero", input: nil, expected: ""},
{name: "NonString_Error", input: 42, expectErr: true},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
name := NewDisplayName("previous")
err := name.Scan(testCase.input)
if testCase.expectErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, testCase.expected, name.StringValue())
value, err := name.Value()
require.NoError(t, err)
assert.Equal(t, testCase.expected, value)
})
}
}
func TestDisplayName_TextRoundTrip(t *testing.T) {
text, err := NewDisplayName(" High CPU ").MarshalText()
require.NoError(t, err)
var name DisplayName
require.NoError(t, name.UnmarshalText(text))
assert.Equal(t, " High CPU ", name.StringValue())
}

View File

@@ -0,0 +1,154 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
import requests
from wiremock.resources.mappings import Mapping
from fixtures import types
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
add_license,
change_user_role,
create_active_user,
)
from fixtures.fs import get_testdata_file_path
from fixtures.metrics import Metrics
from fixtures.role import transaction_group
user_password = "password123Z$"
scoped_role = "infra-scope-key-a"
scoped_email = "infra-scope-key-a@telemetry.test"
wildcard_role = "infra-wildcard"
wildcard_email = "infra-wildcard@telemetry.test"
list_paths = [
"/api/v2/infra_monitoring/hosts",
"/api/v2/infra_monitoring/pods",
"/api/v2/infra_monitoring/kube_containers",
"/api/v2/infra_monitoring/nodes",
"/api/v2/infra_monitoring/namespaces",
"/api/v2/infra_monitoring/clusters",
"/api/v2/infra_monitoring/pvcs",
"/api/v2/infra_monitoring/deployments",
"/api/v2/infra_monitoring/statefulsets",
"/api/v2/infra_monitoring/jobs",
"/api/v2/infra_monitoring/daemonsets",
]
def test_setup(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str],
create_role: Callable[..., str],
) -> None:
add_license(signoz, make_http_mocks, get_token)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_role(admin_token, scoped_role, [transaction_group("read", "telemetryresource", "metrics", ["builder_query/signoz.workspace.key.id/key-a"])])
scoped_user = create_active_user(signoz, admin_token, email=scoped_email, role="signoz-viewer", password=user_password)
change_user_role(signoz, admin_token, scoped_user, "signoz-viewer", scoped_role)
create_role(admin_token, wildcard_role, [transaction_group("read", "telemetryresource", "metrics", ["builder_query/*"])])
wildcard_user = create_active_user(signoz, admin_token, email=wildcard_email, role="signoz-viewer", password=user_password)
change_user_role(signoz, admin_token, wildcard_user, "signoz-viewer", wildcard_role)
@pytest.mark.parametrize("path", list_paths)
@pytest.mark.parametrize(
("email", "expected"),
[(scoped_email, HTTPStatus.FORBIDDEN), (wildcard_email, HTTPStatus.OK), (USER_ADMIN_EMAIL, HTTPStatus.OK)],
)
def test_list_routes(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
path: str,
email: str,
expected: HTTPStatus,
) -> None:
now = datetime.now(tz=UTC)
token = get_token(email, USER_ADMIN_PASSWORD if email == USER_ADMIN_EMAIL else user_password)
response = requests.post(
signoz.self.host_configs["8080"].get(path),
json={"start": int((now - timedelta(minutes=10)).timestamp() * 1000), "end": int(now.timestamp() * 1000), "limit": 10},
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
assert response.status_code == expected, response.text
def test_list_denied_message_names_metrics_wildcard(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
) -> None:
now = datetime.now(tz=UTC)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/infra_monitoring/hosts"),
json={"start": int((now - timedelta(minutes=10)).timestamp() * 1000), "end": int(now.timestamp() * 1000), "limit": 10, "filter": {"expression": "host.name = 'web-1'"}},
headers={"Authorization": f"Bearer {get_token(scoped_email, user_password)}"},
timeout=10,
)
assert response.status_code == HTTPStatus.FORBIDDEN, response.text
assert "builder_query/*" in response.text
@pytest.mark.parametrize(
("email", "expression", "expected", "expected_hosts"),
[
(scoped_email, "signoz.workspace.key.id = 'key-a'", HTTPStatus.OK, {"acc-h1"}),
(scoped_email, "signoz.workspace.key.id = 'key-b'", HTTPStatus.FORBIDDEN, set()),
(scoped_email, "signoz.workspace.key.id IN ('key-a', 'key-b')", HTTPStatus.FORBIDDEN, set()),
(wildcard_email, None, HTTPStatus.OK, {"acc-h1", "acc-h2"}),
],
ids=["scoped_granted_key", "scoped_other_key", "scoped_both_keys", "wildcard_unfiltered"],
)
def test_hosts_scoped_read(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
email: str,
expression: str | None,
expected: HTTPStatus,
expected_hosts: set[str],
) -> None:
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
[Metrics.from_dict({**metric.to_dict(), "labels": {**metric.labels, "signoz.workspace.key.id": "key-a" if metric.labels["host.name"] == "acc-h1" else "key-b"}}) for metric in Metrics.load_from_file(get_testdata_file_path("inframonitoring/hosts_value_accuracy.jsonl"), base_time=now - timedelta(minutes=4))]
)
body: dict = {"start": int((now - timedelta(minutes=5)).timestamp() * 1000), "end": int(now.timestamp() * 1000), "limit": 50}
if expression:
body["filter"] = {"expression": expression}
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/infra_monitoring/hosts"),
json=body,
headers={"Authorization": f"Bearer {get_token(email, user_password)}"},
timeout=10,
)
assert response.status_code == expected, response.text
if expected == HTTPStatus.OK:
assert {record["hostName"] for record in response.json()["data"]["records"]} == expected_hosts
@pytest.mark.parametrize(
("email", "expected"),
[(scoped_email, HTTPStatus.FORBIDDEN), (wildcard_email, HTTPStatus.OK), (USER_ADMIN_EMAIL, HTTPStatus.OK)],
)
def test_checks(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
email: str,
expected: HTTPStatus,
) -> None:
token = get_token(email, USER_ADMIN_PASSWORD if email == USER_ADMIN_EMAIL else user_password)
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/infra_monitoring/checks"),
params={"type": "hosts"},
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
assert response.status_code == expected, response.text

View File

@@ -0,0 +1,150 @@
import uuid
from collections.abc import Callable
from http import HTTPStatus
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
BASE_URL = "/api/v2/rules"
CHANNELS_URL = "/api/v2/notification_channels"
CHANNEL_NAME = f"clone-rule-channel-{uuid.uuid4()}"
SOURCE_RULE = {
"alert": "clone me",
"description": "source rule for clone",
"alertType": "METRIC_BASED_ALERT",
"ruleType": "threshold_rule",
"condition": {
"thresholds": {
"kind": "basic",
"spec": [{"name": "critical", "target": 90, "matchType": "at_least_once", "op": "above", "channels": [CHANNEL_NAME]}],
},
"compositeQuery": {
"queryType": "builder",
"panelType": "graph",
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{"metricName": "clone_rule_cpu", "timeAggregation": "avg", "spaceAggregation": "max"}],
},
}
],
},
"selectedQueryName": "A",
},
"labels": {"severity": "critical", "team": "payments"},
"annotations": {"summary": "s", "description": "d"},
"disabled": True,
"evaluation": {"kind": "rolling", "spec": {"evalWindow": "5m0s", "frequency": "1m"}},
"notificationSettings": {
"groupBy": [],
"usePolicy": False,
"renotify": {"enabled": False, "interval": "30m", "alertStates": []},
},
"version": "v5",
"schemaVersion": "v2alpha1",
}
def test_clone_rule(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get(CHANNELS_URL),
json={"name": CHANNEL_NAME, "config": {"kind": "webhook", "spec": {"url": "https://webhook.test/hook"}}},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
channel_id = response.json()["data"]["id"]
response = requests.post(
signoz.self.host_configs["8080"].get(BASE_URL),
json=SOURCE_RULE,
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
source_id = response.json()["data"]["id"]
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{source_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
source = response.json()["data"]
# clone keeps everything but the id and name
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{source_id}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
clone = response.json()["data"]
assert clone["id"] != source_id
assert clone["alert"] == "clone me - Copy"
for field in ["description", "alertType", "ruleType", "disabled", "labels", "annotations", "condition", "evaluation", "notificationSettings"]:
assert clone[field] == source[field], field
response = requests.get(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{clone['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["data"]["alert"] == "clone me - Copy"
assert response.json()["data"]["state"] == "disabled"
assert response.json()["data"]["createdBy"] == USER_ADMIN_EMAIL
# cloning a copy bumps the counter
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{clone['id']}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.CREATED, response.text
second_clone = response.json()["data"]
assert second_clone["alert"] == "clone me - Copy (2)"
assert second_clone["condition"] == source["condition"]
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
response = requests.post(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/not-a-uuid/clone"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
for rule_id in [source_id, clone["id"], second_clone["id"]]:
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{rule_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text
response = requests.delete(
signoz.self.host_configs["8080"].get(f"{CHANNELS_URL}/{channel_id}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.NO_CONTENT, response.text