Compare commits

..

10 Commits

Author SHA1 Message Date
Aditya Singh
6c6632f940 test(saved-views): authz stories for each missing permission (#13172)
Some checks are pending
build-staging / staging (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description
- The saved views stories now have an `Authz` folder, with one story per
missing permission. The stories follow the dashboard and billing authz
stories.
- `NoList`, `NoCreate`, `NoRead`, `NoUpdate` and `NoDelete` each deny
one permission.
- `NoCreateSaveMenu`, `NoUpdateSaveMenu` and `NoReadOpenView` show the
same denials in the `✓` menu and on an open view.
  - `ReadOnly` denies create, update and delete together.
- `CheckLoading` and `CheckFailed` cover a pending check and a failed
check.
- Each story checks what is disabled and what still works.
- The main stories move to `Components/Saved Views/Overview`, and
`NoAccess` is now `NoList`.
- The header `✓` and `✕` buttons get a gap again. After the ui upgrade
they are link buttons without padding, so they touched.

#### Issues closed by this PR

#### Screenshot

Before

<img width="608" height="120" alt="image"
src="https://github.com/user-attachments/assets/e9fcf890-3355-4cd5-82e4-a4e0221aaacd"
/>


After

<img width="734" height="212" alt="image"
src="https://github.com/user-attachments/assets/7ce0d2d3-7155-4365-bbab-aad0fef6fc29"
/>


Part of https://github.com/SigNoz/events-pod/issues/71
2026-10-16 19:26:53 +00:00
Aditya Singh
39b752c428 feat(logs): move the list row filter chips off v3 attribute keys (#13148)
#### Description

- The "Add to query" chip on a logs list row no longer fetches v3
`attribute_keys` before it adds the filter. The hook builds the filter
item with the same util the log details drawer uses, then redirects. The
stored key has an empty data type and context. The filter expression is
built from key and value, so the request is unchanged.
- `LogDetail` dropped two props it never read, `onAddToQuery` and
`onClickActionItem`, from its type and from six call sites.
- Removed the code that only existed for those props: the `EntityLogs`
callback, `generateFilterQuery`, `getOldLogsOperatorFromNew` and
`ActionItemProps`. Removed `onGroupByAttribute` from `useActiveLog`,
which nothing called.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/160


#### Screenshot



https://github.com/user-attachments/assets/7cce500a-6dbf-45cb-937f-7366d515b0b9



https://github.com/user-attachments/assets/f5cbd1bd-0863-4633-ad6e-2fd15f7ae7c5



https://github.com/user-attachments/assets/2fb7dbeb-c474-4730-9a9d-363257a7fff7



#### Additional Information

- Only one user path runs the changed hook: the field chips on a log row
in the logs explorer list and in live logs.
- The merged where clause shows adjacent clauses without `AND`. The
grammar allows an implicit AND and the query runs. This is pre-existing
in `convertFiltersToExpressionWithExistingQuery` and is not changed
here.
- Part of https://github.com/SigNoz/events-pod/issues/156.
2026-10-16 18:32:25 +00:00
Aditya Singh
dccd75e1de feat(saved-views): gate saved views on their permissions (#13027)
#### Description
- Saved views now follow the `saved-view` permissions:
  - `list` gates the views panel.
  - `create` gates `+`, "Save as new view" and "Create view".
- `read` gates opening a view. List rows for views the user cannot read
are disabled, with the reason on hover.
  - `update` and `delete` gate the row menu and the `✓` menu, per view.
- Opening a view checks `read` first. Without it, the view is not
fetched and the header shows "No view access".
- Denied buttons and menu items stay visible. They are disabled and show
the reason on hover. They use `AuthZButton` and `AuthZDropdown`.
- The row menu has "Copy ID", so a user can copy a view's id to use in a
role.
- The authz generator now includes `saved-view`, and roles settings gets
a Saved Views panel.
- Stories for the header and the views panel are under `Components/Saved
Views`.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/76


#### Screenshot / Screenrecording



https://github.com/user-attachments/assets/c6505fb1-5a57-4c61-ad7e-09531d500002

<img width="490" height="982" alt="Screenshot 2026-10-15 at 12 13 00 PM"
src="https://github.com/user-attachments/assets/b8ab7760-c0c9-4ee7-8201-c8dae8cdfd57"
/>


<img width="349" height="567" alt="Screenshot 2026-10-15 at 12 13 32 PM"
src="https://github.com/user-attachments/assets/ee7e0ebc-e929-4ff1-a624-e605fe09cb95"
/>


<img width="693" height="955" alt="Screenshot 2026-10-15 at 12 14 10 PM"
src="https://github.com/user-attachments/assets/023c022a-5105-471a-acce-835251f9ea3f"
/>


<img width="1458" height="2016" alt="image"
src="https://github.com/user-attachments/assets/c33d5e65-dae7-454e-a5e0-2b7796e204cc"
/>


#### Additional Information
- The change is behind `SAVED_VIEW_ENABLED`.
- On staging, the per view update and delete checks do not show in the
AuthZ DevTools. List and create work. The backend team must check this
before the PR moves out of draft.
- The save as new prompt and the unsaved changes nudge are not in this
PR.
2026-10-16 17:32:31 +00:00
Nikhil Mantri
4f3e7fa50e feat(ruler): add clone alert rule API (#13093)
#### Description

- Adds `POST /api/v2/rules/{id}/clone`, a server-side clone for alert
rules. The caller passes only the source rule's id and gets back the
newly created rule.
- Until now, cloning was done entirely by the frontend, which re-posted
the whole rule it had in memory to the create endpoint. The new endpoint
copies the rule from what is actually persisted, so clones are
consistent regardless of the client.
- The clone keeps every modeled field of the source, including its
enabled or disabled state, and is owned by the user who cloned it.
Cloning fails with a 400 that names the source rule when it no longer
passes create validation, for example when its channel has been deleted.
- The name follows the convention introduced by the dashboard clone API:
`My Alert - Copy`, then `My Alert - Copy (2)`, `My Alert - Copy (3)`
when a copy is cloned again. The same suffix logic was adopted, minus
the length cap, since alert names have no limit today.
- The endpoint reuses the existing create path, so validation,
notification routing and evaluator start-up behave exactly as a manual
create.
- Includes unit tests for the naming and copy logic and an integration
test covering clone, clone of a clone, unknown id and malformed id.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#228

#### Additional Information

- Backend only. The frontend still uses the client-side approach and can
switch to this endpoint in a follow-up.
- Names are not de-duplicated against existing rules, matching the
dashboard clone behaviour. Rule names are not unique today, so a
collision is harmless.
2026-10-16 17:23:32 +00:00
Nityananda Gohain
531504c523 fix(tracedetail): keep litellm tool calls and AI SDK JSON answers, mark redacted media (#13166)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- AI SDK: a JSON answer such as `{"answer": 42}` was dropped, and a JSON
list was read as nameless tool calls. The output is now tool calls only
when every item has `toolName`;
  anything else is kept as text.
- litellm: parallel calls (`gen_ai.completion.N` numbers tool calls, not
choices), a call next to text, and the history's `tool_calls` /
`tool_call_id` kept beside `parts` now become tool call and tool
response parts.
- Redaction placeholders (OpenInference's `__REDACTED__`, OpenRouter's
`[OR_REDACTED]`) no longer become `uri` parts: media without a URL
scheme is a generic part, with a warning.

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

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

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

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



#### Additional Information

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

#### Performance Impact
Almost none

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

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

## Additional Information

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

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

---------

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

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

#### Screenshots

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


#### Additional Information

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

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

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

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

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

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

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

---------

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

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

#### Issues closed by this PR

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

View File

@@ -104,6 +104,7 @@ func runGenerateAuthz(_ context.Context) error {
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceMeterMetrics).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceQuickFilter).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSavedView).String(): true,
}
allowedTypes := map[string]bool{}

View File

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

@@ -9,12 +9,14 @@ describe('ErrorResponseHandler', () => {
.spyOn(console, 'error')
.mockImplementation(() => undefined);
expect(ErrorResponseHandler(new CanceledError() as AxiosError)).toEqual({
statusCode: 500,
payload: null,
error: 'Something went wrong',
message: null,
});
expect(ErrorResponseHandler(new CanceledError() as AxiosError)).toStrictEqual(
{
statusCode: 500,
payload: null,
error: 'Something went wrong',
message: null,
},
);
expect(consoleError).not.toHaveBeenCalled();
consoleError.mockRestore();

View File

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

View File

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

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

@@ -1,7 +1,5 @@
import { DrawerProps } from 'antd';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { AddToQueryHOCProps } from 'components/Logs/AddToQueryHOC';
import { ActionItemProps } from 'container/LogDetailedView/LogDetailedView.types';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
@@ -20,9 +18,7 @@ export type LogDetailProps = {
handleOpenInExplorer?: MouseEventHandler;
getContainer?: DrawerProps['getContainer'];
onApplyLogFilter?: (expression: string) => void;
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
Pick<DrawerProps, 'onClose'>;
} & Pick<DrawerProps, 'onClose'>;
export type LogDetailInnerProps = LogDetailProps & {
log: NonNullable<LogDetailProps['log']>;

View File

@@ -46,8 +46,6 @@ function renderDrawer(props: Partial<LogDetailProps> = {}): void {
<LogDetail
log={mockLog}
selectedTab={VIEW_TYPES.OVERVIEW}
onAddToQuery={jest.fn()}
onClickActionItem={jest.fn()}
onClose={jest.fn()}
{...props}
/>,

View File

@@ -1,7 +1,7 @@
import { Button } from '@signozhq/ui/button';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import { render, screen, userEvent } from 'tests/test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { QuickFiltersSource } from '../../types';
import QuickFiltersLayout from '../QuickFiltersLayout';
@@ -197,6 +197,11 @@ describe('QuickFiltersLayout', () => {
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
await waitFor(() =>
expect(screen.getByTestId('saved-views-open')).not.toHaveAttribute(
'aria-busy',
),
);
await user.click(screen.getByTestId('saved-views-open'));
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
@@ -227,6 +232,11 @@ describe('QuickFiltersLayout', () => {
'data-source',
SavedviewtypesSourceDTO.traces,
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-open')).not.toHaveAttribute(
'aria-busy',
),
);
await userEvent.setup().click(screen.getByTestId('saved-views-open'));
expect(screen.getByTestId('saved-views-panel')).toHaveAttribute(
'data-source',

View File

@@ -25,10 +25,8 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { generateFilterQuery } from 'lib/logs/generateFilterQuery';
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
import { ILog } from 'types/api/logs/log';
import { DataSource } from 'types/common/queryBuilder';
@@ -82,29 +80,6 @@ function EntityLogsContent({
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
useLogDetailHandlers();
// TODO: Move away from using onAddToQuery after old drawer cleanup
const onAddToQuery = useCallback(
(fieldKey: string, fieldValue: string, operator: string): void => {
handleCloseLogDetail();
const partExpression = generateFilterQuery({
fieldKey,
fieldValue,
type: getOldLogsOperatorFromNew(operator),
});
const currentUser = userExpression;
const newUser = currentUser.trim()
? `${currentUser} AND ${partExpression}`
: partExpression;
querySearchOnRun(newUser);
logInfraDrawerFilterCustomizedEvent(category, 'logs', newUser, 'logs');
},
[userExpression, querySearchOnRun, handleCloseLogDetail, category],
);
const onApplyLogFilter = useCallback(
(expression: string): void => {
handleCloseLogDetail();
@@ -342,8 +317,6 @@ function EntityLogsContent({
logs={logs}
onNavigateLog={handleSetActiveLog}
selectedTab={selectedTab}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onApplyLogFilter={onApplyLogFilter}
onScrollToLog={handleScrollToLog}
handleOpenInExplorer={(e) => handleOpenInExplorer(e, activeLog)}

View File

@@ -259,8 +259,6 @@ function LiveLogsList({
selectedTab={selectedTab}
log={activeLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
logs={formattedLogs}
onNavigateLog={handleSetActiveLog}

View File

@@ -1,5 +1,4 @@
import { MetricsType } from 'container/MetricsApplication/constant';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
export type AnyObject = { [key: string]: any };
@@ -19,15 +18,3 @@ export interface DataType {
field: string;
value: string;
}
export interface ActionItemProps {
fieldKey: string;
fieldValue: string;
onClickActionItem: (
fieldKey: string,
fieldValue: string,
operator: string,
dataType?: DataTypes,
fieldType?: string,
) => void;
}

View File

@@ -172,7 +172,7 @@ const normalizeDataType = (
// key is fabricated locally (empty source list), so no click-time getAggregateKeys fetch.
export const getFilterQueryData = (
item: IBuilderQuery,
target: LogFilterTarget,
target: Pick<LogFilterTarget, 'fieldKey' | 'dataType' | 'metricsType'>,
value: unknown,
operator: string,
): IBuilderQuery => {

View File

@@ -381,8 +381,6 @@ function LogsExplorerList({
selectedTab={selectedTab}
log={activeLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
logs={logs}
onNavigateLog={handleSetActiveLog}

View File

@@ -91,13 +91,8 @@ function LogsPanelComponent({
() => logs.map((log) => FlatLogData(log) as RowData),
[logs],
);
const {
activeLog,
onAddToQuery,
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
} = useLogDetailHandlers();
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
useLogDetailHandlers();
const handleRow = useCallback(
(record: RowData): HTMLAttributes<RowData> => ({
@@ -179,8 +174,6 @@ function LogsPanelComponent({
selectedTab={selectedTab}
log={activeLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
isListViewPanel
listViewPanelSelectedFields={widget?.selectedLogFields}
logs={logs}

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

@@ -10,8 +10,7 @@ import { ILog } from 'types/api/logs/log';
import './styles.scss';
function LogsList({ logs }: LogsListProps): JSX.Element {
const { activeLog, onSetActiveLog, onClearActiveLog, onAddToQuery } =
useActiveLog();
const { activeLog, onSetActiveLog, onClearActiveLog } = useActiveLog();
const makeLogDetailsHandler = (log: ILog) => (): void => onSetActiveLog(log);
@@ -45,8 +44,6 @@ function LogsList({ logs }: LogsListProps): JSX.Element {
selectedTab={VIEW_TYPES.OVERVIEW}
log={activeLog}
onClose={onClearActiveLog}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
/>
</div>
);

View File

@@ -329,7 +329,7 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
const result = transformTransactionGroupsToResourcePermissions([]);
expect(result).toHaveLength(11);
expect(result).toHaveLength(12);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'dashboard',
@@ -339,6 +339,7 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
'metrics',
'quick-filter',
'role',
'saved-view',
'serviceaccount',
'subscription',
'traces',
@@ -422,7 +423,7 @@ describe('createEmptyRolePermissions', () => {
it('creates permissions for all resources in RESOURCE_ORDER', () => {
const result = createEmptyRolePermissions();
expect(result).toHaveLength(11);
expect(result).toHaveLength(12);
expect(result.map((r) => r.resourceKind)).toStrictEqual([
'factor-api-key',
'dashboard',
@@ -432,6 +433,7 @@ describe('createEmptyRolePermissions', () => {
'metrics',
'quick-filter',
'role',
'saved-view',
'serviceaccount',
'subscription',
'traces',

View File

@@ -10,6 +10,7 @@ import {
Logs,
Receipt,
Shield,
TowerControl,
} from '@signozhq/icons';
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
@@ -95,6 +96,14 @@ export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
'Type quick filter ID, separate multiple with comma or space',
docsAnchor: 'quick-filter',
},
'saved-view': {
label: 'Saved Views',
description: 'Saved views in the logs, traces, metrics and meter explorers.',
icon: TowerControl,
selectorPlaceholder:
'Type saved view ID, separate multiple with comma or space',
docsAnchor: 'saved-view',
},
logs: {
label: 'Logs',
description: 'Log data collected across the workspace.',

View File

@@ -1,41 +1,50 @@
import { useMemo } from 'react';
import { Check } from '@signozhq/icons';
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import AuthZDropdown from 'lib/authz/components/AuthZDropdown/AuthZDropdown';
import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/types';
import {
buildSavedViewUpdatePermission,
SavedViewCreatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import SavedViewsIconButton from './SavedViewsIconButton';
import { SaveChangesMenuProps } from './types';
function SaveChangesMenu({
viewId,
disabled,
onSaveAsNew,
onUpdate,
}: SaveChangesMenuProps): JSX.Element {
const items = useMemo<DropdownItemType[]>(
const items = useMemo<AuthZDropdownItemType[]>(
() => [
{
type: 'item',
value: 'save-as-new',
label: 'Save as new view',
checks: [SavedViewCreatePermission],
onClick: onSaveAsNew,
},
{
type: 'item',
value: 'update',
label: 'Update selected view',
checks: [buildSavedViewUpdatePermission(viewId)],
onClick: onUpdate,
},
],
[onSaveAsNew, onUpdate],
[viewId, onSaveAsNew, onUpdate],
);
return (
<Dropdown
<AuthZDropdown
items={items}
nativeButton
align="end"
side="bottom"
disabled={disabled}
disabledTooltip="Saving the view"
testId="saved-views-save-changes"
>
<SavedViewsIconButton
title="Save changes"
@@ -43,7 +52,7 @@ function SaveChangesMenu({
color="warning"
testId="saved-views-save-changes"
/>
</Dropdown>
</AuthZDropdown>
);
}

View File

@@ -1,7 +1,8 @@
import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { SavedViewCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import SaveViewModal from './SaveViewModal';
import { SavedViewsEmptyStateProps } from './types';
@@ -31,7 +32,8 @@ function SavedViewsEmptyState({
<Typography.Text className={styles.stateText}>
Save the current view to open it later.
</Typography.Text>
<Button
<AuthZButton
checks={[SavedViewCreatePermission]}
variant="outlined"
color="secondary"
size="sm"
@@ -40,7 +42,7 @@ function SavedViewsEmptyState({
testId="saved-views-list-create"
>
Create view
</Button>
</AuthZButton>
{isModalOpen && (
<SaveViewModal
mode="create"

View File

@@ -78,7 +78,7 @@
display: flex;
align-items: center;
flex-shrink: 0;
gap: var(--spacing-1);
gap: var(--spacing-4);
}
.collapseIcon {

View File

@@ -5,9 +5,17 @@ import { Skeleton } from '@signozhq/ui/skeleton';
import { toast } from '@signozhq/ui/sonner';
import { Tooltip } from '@signozhq/ui/tooltip';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import {
buildSavedViewReadPermission,
SavedViewCreatePermission,
SavedViewListPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import {
MY_VIEW_NAME,
SAVED_VIEW_FORBIDDEN_NAME,
SAVED_VIEW_LOAD_FAILED_NAME,
SAVED_VIEW_TOAST_POSITION,
} from './constants';
@@ -31,8 +39,16 @@ function SavedViewsHeader({
onOpenViews?: () => void;
onCollapse?: () => void;
}): JSX.Element {
const { view, isLoading, isError, hasUnsavedChanges } =
useActiveSavedView(source);
const {
view,
isLoading,
isError,
isReadDenied,
errorMessage,
hasUnsavedChanges,
} = useActiveSavedView(source);
const { viewKey } = useGetSavedViewParams();
const readPermission = buildSavedViewReadPermission(viewKey);
const { revertView, clearView, createView, updateView, isSaving } =
useSavedViewActions(source);
@@ -49,11 +65,27 @@ function SavedViewsHeader({
const openSaveAsNew = useCallback((): void => setModalMode('saveAsNew'), []);
const closeModal = useCallback((): void => setModalMode(null), []);
const name = isError
? SAVED_VIEW_LOAD_FAILED_NAME
: (view?.spec.displayName ?? MY_VIEW_NAME);
let name = view?.spec.displayName ?? MY_VIEW_NAME;
if (isReadDenied) {
name = SAVED_VIEW_FORBIDDEN_NAME;
} else if (isError) {
name = SAVED_VIEW_LOAD_FAILED_NAME;
}
const isDirty = !isError && !!view && hasUnsavedChanges;
const nameChip = (
<span
className={cx(styles.name, {
[styles.isDirty]: isDirty,
[styles.isError]: isError,
})}
data-testid="saved-views-name"
>
{isDirty && <span className={styles.dot} />}
<span className={styles.nameText}>{name}</span>
</span>
);
return (
<div
className={cx(styles.header, {
@@ -68,6 +100,7 @@ function SavedViewsHeader({
<SavedViewsIconButton
title="All views"
icon={<List size={14} />}
checks={[SavedViewListPermission]}
onClick={onOpenViews}
testId="saved-views-open"
/>
@@ -79,19 +112,10 @@ function SavedViewsHeader({
className={styles.skeleton}
testId="saved-views-loading"
/>
) : isReadDenied ? (
<AuthZTooltip checks={[readPermission]}>{nameChip}</AuthZTooltip>
) : (
<Tooltip title={name}>
<span
className={cx(styles.name, {
[styles.isDirty]: isDirty,
[styles.isError]: isError,
})}
data-testid="saved-views-name"
>
{isDirty && <span className={styles.dot} />}
<span className={styles.nameText}>{name}</span>
</span>
</Tooltip>
<Tooltip title={errorMessage ?? name}>{nameChip}</Tooltip>
)}
</div>
@@ -102,6 +126,7 @@ function SavedViewsHeader({
icon={<Plus size={14} />}
disabled={isLoading}
disabledTooltip="Loading views"
checks={[SavedViewCreatePermission]}
onClick={(): void => setModalMode('create')}
testId="saved-views-create"
/>
@@ -117,6 +142,7 @@ function SavedViewsHeader({
{view && isDirty && (
<>
<SaveChangesMenu
viewId={view.id}
disabled={isSaving}
onSaveAsNew={openSaveAsNew}
onUpdate={(): void => {

View File

@@ -1,6 +1,7 @@
import { forwardRef } from 'react';
import { Button, type VariantColorType } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { SavedViewsIconButtonProps } from './types';
@@ -25,11 +26,33 @@ const SavedViewsIconButton = forwardRef<
color = 'secondary',
disabled,
disabledTooltip,
checks,
onClick,
...triggerProps
},
ref,
): JSX.Element {
if (checks?.length) {
return (
<Tooltip title={title}>
<AuthZButton
checks={checks}
{...VARIANT_COLORS[color]}
size="sm"
icon
aria-label={title}
disabled={disabled}
// AuthZButton skips its checks while a block reason is set.
disabledTooltip={disabled ? disabledTooltip : undefined}
onClick={onClick}
testId={testId}
>
{icon}
</AuthZButton>
</Tooltip>
);
}
return (
<Tooltip title={title}>
<Button

View File

@@ -41,7 +41,7 @@
.row {
display: flex;
align-items: center;
gap: var(--spacing-2);
gap: var(--spacing-4);
height: var(--spacing-16);
padding-right: var(--spacing-4);
border-radius: var(--radius-2);
@@ -71,7 +71,7 @@
.row:hover .rowMenu,
.row:focus-within .rowMenu,
.rowMenu:has([data-state='open']) {
.rowMenu:has([data-popup-open]) {
width: auto;
opacity: 1;
}

View File

@@ -40,8 +40,14 @@ function SavedViewsList({
const saveNewView = useSaveNewView(createView);
const { createdByMe, createdByOthers, isLoading, isError, refetch } =
useSavedViewsList(source, search);
const { copyViewLink, renameView, deleteView, isRenaming, isDeleting } =
useSavedViewRowActions(source);
const {
copyViewLink,
copyViewId,
renameView,
deleteView,
isRenaming,
isDeleting,
} = useSavedViewRowActions(source);
const [pendingAction, setPendingAction] =
useState<PendingSavedViewAction | null>(null);
const listRef = useRef<HTMLDivElement>(null);
@@ -79,10 +85,14 @@ function SavedViewsList({
copyViewLink(view);
return;
}
if (key === 'copyId') {
copyViewId(view);
return;
}
closeHoverCard();
setPendingAction({ key, view });
},
[copyViewLink, closeHoverCard],
[copyViewLink, copyViewId, closeHoverCard],
);
const closePendingAction = useCallback((): void => setPendingAction(null), []);

View File

@@ -1,6 +1,8 @@
import cx from 'classnames';
import { Undo2 } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import SavedViewsRowButton from './SavedViewsRowButton';
import SavedViewsRowMenu from './SavedViewsRowMenu';
@@ -17,17 +19,26 @@ function SavedViewsListRow({
hover,
}: SavedViewsListRowProps): JSX.Element {
const name = view.spec.displayName;
const readPermission = buildSavedViewReadPermission(view.id);
// Same cached check as the button; gates the hover card.
const { isLoading, deniedPermissions } = useAuthZ([readPermission]);
const canRead = !isLoading && deniedPermissions.length === 0;
return (
<div
className={cx(styles.row, { [styles.isActive]: isActive })}
data-testid="saved-views-row"
data-view-id={view.id}
onMouseEnter={(event): void => hover.onRowEnter(view, event.currentTarget)}
onMouseEnter={(event): void => {
if (canRead) {
hover.onRowEnter(view, event.currentTarget);
}
}}
onMouseLeave={hover.onRowLeave}
>
<div className={styles.rowName}>
<Button
<AuthZButton
checks={[readPermission]}
size="md"
variant="ghost"
color="secondary"
@@ -41,7 +52,7 @@ function SavedViewsListRow({
testId="saved-views-row-name"
>
{name}
</Button>
</AuthZButton>
</div>
{isActive && (
<SavedViewsRowButton
@@ -54,6 +65,7 @@ function SavedViewsListRow({
)}
<span className={styles.rowMenu}>
<SavedViewsRowMenu
viewId={view.id}
onAction={(key): void => onAction(view, key)}
onOpen={hover.onMenuOpen}
/>

View File

@@ -4,6 +4,9 @@ import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import { SavedViewListPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import SavedViewsIconButton from './SavedViewsIconButton';
import SavedViewsList from './SavedViewsList';
@@ -39,16 +42,20 @@ function SavedViewsPanel({
/>
</div>
)}
<div className={styles.search}>
<Input
value={search}
onChange={(event): void => setSearch(event.target.value)}
placeholder="Search..."
aria-label="Search views"
testId="saved-views-search"
/>
</div>
<SavedViewsList source={source} search={search} />
<AuthZGuardContent checks={[SavedViewListPermission]}>
<>
<div className={styles.search}>
<Input
value={search}
onChange={(event): void => setSearch(event.target.value)}
placeholder="Search..."
aria-label="Search views"
testId="saved-views-search"
/>
</div>
<SavedViewsList source={source} search={search} />
</>
</AuthZGuardContent>
</div>
);
}

View File

@@ -1,16 +1,18 @@
import { useMemo } from 'react';
import { Ellipsis } from '@signozhq/icons';
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import AuthZDropdown from 'lib/authz/components/AuthZDropdown/AuthZDropdown';
import type { AuthZDropdownItemType } from 'lib/authz/components/AuthZDropdown/types';
import { SAVED_VIEW_ROW_ACTIONS } from './constants';
import SavedViewsRowButton from './SavedViewsRowButton';
import { SavedViewsRowMenuProps } from './types';
function SavedViewsRowMenu({
viewId,
onAction,
onOpen,
}: SavedViewsRowMenuProps): JSX.Element {
const items = useMemo<DropdownItemType[]>(
const items = useMemo<AuthZDropdownItemType[]>(
() =>
SAVED_VIEW_ROW_ACTIONS.flatMap(
({
@@ -19,7 +21,8 @@ function SavedViewsRowMenu({
icon: Icon,
danger,
hasDividerBefore,
}): DropdownItemType[] => [
buildPermission,
}): AuthZDropdownItemType[] => [
...(hasDividerBefore
? [{ type: 'separator' as const, value: `separator-${key}` }]
: []),
@@ -29,15 +32,16 @@ function SavedViewsRowMenu({
label,
prefix: <Icon size={14} />,
danger,
checks: buildPermission ? [buildPermission(viewId)] : undefined,
onClick: (): void => onAction(key),
},
],
),
[onAction],
[viewId, onAction],
);
return (
<Dropdown
<AuthZDropdown
items={items}
nativeButton
side="right"
@@ -47,6 +51,7 @@ function SavedViewsRowMenu({
onOpen();
}
}}
testId="saved-views-row-menu"
>
<SavedViewsRowButton
icon={<Ellipsis size={14} />}
@@ -54,7 +59,7 @@ function SavedViewsRowMenu({
tooltip="More options"
testId="saved-views-row-menu"
/>
</Dropdown>
</AuthZDropdown>
);
}

View File

@@ -1,4 +1,10 @@
import { act, fireEvent, screen, within } from '@testing-library/react';
import {
act,
fireEvent,
screen,
waitFor,
within,
} from '@testing-library/react';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
@@ -12,6 +18,7 @@ import {
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
expectUsable,
explorerUrl,
makeView,
queryWith,
@@ -110,6 +117,11 @@ async function renderPanel(
dataSource,
);
await screen.findByTestId('saved-views-list');
await waitFor(() =>
screen
.getAllByTestId('saved-views-row-name')
.forEach((name) => expectUsable(name)),
);
jest.useFakeTimers();
}
@@ -140,6 +152,10 @@ describe('SavedViewHoverCard', () => {
});
afterEach(() => {
// useAuthZ batches checks on a timer; left pending, it blocks later checks.
act(() => {
jest.runOnlyPendingTimers();
});
jest.useRealTimers();
});

View File

@@ -0,0 +1,265 @@
import { fireEvent, 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 {
buildSavedViewDeletePermission,
buildSavedViewReadPermission,
buildSavedViewUpdatePermission,
SavedViewCreatePermission,
SavedViewListPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import {
AUTHZ_CHECK_URL,
setupAuthzDeny,
} from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { DataSource } from 'types/common/queryBuilder';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
import { API, mockSavedViewsApi } from './savedViewsApiMock';
import {
explorerUrl,
makeView,
MENU_CHECK_TIMEOUT,
queryWith,
renderWithExplorerProviders,
viewUrl,
} from './savedViewsTestUtils';
const PATH = ROUTES.TRACES_EXPLORER;
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const slow = makeView({ id: 'view-2', displayName: 'Slow spans' });
function renderPanel(url = explorerUrl(PATH, {})): void {
renderWithExplorerProviders(
<SavedViewsPanel
source={SavedviewtypesSourceDTO.traces}
onClose={jest.fn()}
/>,
url,
);
}
function renderHeader(
url: string,
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
/>,
url,
);
}
const rowNamed = (name: string): HTMLElement =>
screen
.getAllByTestId('saved-views-row')
.find((row) => row.textContent?.includes(name)) as HTMLElement;
async function openRowMenu(name: string): Promise<void> {
await screen.findAllByTestId('saved-views-row');
fireEvent.click(within(rowNamed(name)).getByTestId('saved-views-row-menu'));
}
// Resolves once the row's check is back, so its state is final.
async function settledRow(testId: string): Promise<HTMLElement> {
await waitFor(
() => expect(screen.getByTestId(testId)).not.toHaveAttribute('data-loading'),
MENU_CHECK_TIMEOUT,
);
return screen.getByTestId(testId);
}
describe('SavedViews - AuthZ', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
server.resetHandlers();
});
it('shows the denied callout instead of the list without list', async () => {
mockSavedViewsApi([errors]);
server.use(setupAuthzDeny(SavedViewListPermission));
renderPanel();
await expect(
screen.findByText(/not authorized/i),
).resolves.toBeInTheDocument();
expect(screen.queryByTestId('saved-views-list')).toBeNull();
expect(screen.queryByTestId('saved-views-search')).toBeNull();
});
it('disables only the views it cannot read', async () => {
mockSavedViewsApi([errors, slow]);
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
renderPanel();
await screen.findAllByTestId('saved-views-row');
const nameOf = (view: string): HTMLElement =>
within(rowNamed(view)).getByTestId('saved-views-row-name');
await waitFor(() =>
expect(nameOf('Errors')).toHaveAttribute('aria-disabled', 'true'),
);
await waitFor(() =>
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-busy'),
);
expect(nameOf('Slow spans')).not.toHaveAttribute('aria-disabled', 'true');
});
it('keeps the row menu on a view it cannot read', async () => {
mockSavedViewsApi([errors]);
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
renderPanel();
await openRowMenu('Errors');
await expect(
settledRow('saved-views-row-menu-item-delete'),
).resolves.not.toHaveAttribute('data-disabled');
await expect(
settledRow('saved-views-row-menu-item-editDetails'),
).resolves.not.toHaveAttribute('data-disabled');
await expect(
settledRow('saved-views-row-menu-item-copyLink'),
).resolves.not.toHaveAttribute('data-disabled');
});
it('disables creating without create', async () => {
mockSavedViewsApi([]);
server.use(setupAuthzDeny(SavedViewCreatePermission));
renderPanel();
renderHeader(explorerUrl(PATH, {}));
await waitFor(() =>
expect(screen.getByTestId('saved-views-list-create')).toHaveAttribute(
'aria-disabled',
'true',
),
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-create')).toHaveAttribute(
'aria-disabled',
'true',
),
);
});
it('keeps create disabled while the check is pending', async () => {
mockSavedViewsApi([]);
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.delay('infinite'))),
);
renderHeader(explorerUrl(PATH, {}));
const create = screen.getByTestId('saved-views-create');
expect(create).toHaveAttribute('aria-busy', 'true');
await userEvent.click(create);
expect(screen.queryByTestId('save-view-name')).toBeNull();
});
it('disables edit details without update and delete without delete', async () => {
mockSavedViewsApi([errors]);
server.use(
setupAuthzDeny(
buildSavedViewUpdatePermission(errors.id),
buildSavedViewDeletePermission(errors.id),
),
);
renderPanel();
await openRowMenu('Errors');
await expect(
settledRow('saved-views-row-menu-item-editDetails'),
).resolves.toHaveAttribute('data-disabled');
await expect(
settledRow('saved-views-row-menu-item-delete'),
).resolves.toHaveAttribute('data-disabled');
await expect(
settledRow('saved-views-row-menu-item-copyLink'),
).resolves.not.toHaveAttribute('data-disabled');
});
it('disables updating the open view without update, keeping save as new', async () => {
mockSavedViewsApi([errors]);
server.use(setupAuthzDeny(buildSavedViewUpdatePermission(errors.id)));
const { history } = renderHeader(viewUrl(PATH, errors));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('Errors'),
);
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
);
await userEvent.click(await screen.findByTestId('saved-views-save-changes'));
await expect(
settledRow('saved-views-save-changes-item-update'),
).resolves.toHaveAttribute('data-disabled');
await expect(
settledRow('saved-views-save-changes-item-save-as-new'),
).resolves.not.toHaveAttribute('data-disabled');
});
it('says no access when the check denies read, even if the server returns the view', async () => {
mockSavedViewsApi([errors]);
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
renderHeader(viewUrl(PATH, errors));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
'No view access',
),
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveAttribute(
'data-denied-permissions',
),
);
await userEvent.setup().hover(screen.getByTestId('saved-views-name'));
await waitFor(() =>
expect(
screen.queryAllByText(/is not authorized to perform read:saved-view:view-1/)
.length,
).toBeGreaterThan(0),
);
});
it('treats a 403 the check allowed as a failed load, showing the server message', async () => {
const message = 'saved-view:read is missing, ask an admin for it';
mockSavedViewsApi([errors]);
server.use(
rest.get(`${API}/:id`, (_req, res, ctx) =>
res(
ctx.status(403),
ctx.json({ status: 'error', error: { code: 'forbidden', message } }),
),
),
);
renderHeader(viewUrl(PATH, errors));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
'View failed to load',
),
);
await userEvent.setup().hover(screen.getByTestId('saved-views-name'));
await waitFor(() =>
expect(screen.queryAllByText(message).length).toBeGreaterThan(0),
);
});
});

View File

@@ -9,7 +9,9 @@ import { usePreferenceContext } from 'providers/preferences/context/PreferenceCo
import SavedViewsHeader from '../SavedViewsHeader';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
expectRowUsable,
makeView,
MENU_CHECK_TIMEOUT,
renderWithExplorerProviders,
viewUrl,
} from './savedViewsTestUtils';
@@ -115,9 +117,11 @@ describe('SavedViewsHeader columns', () => {
await userEvent.click(screen.getByTestId('add-column'));
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
await userEvent.click(
await screen.findByRole('menuitem', { name: 'Update selected view' }),
const update = await screen.findByTestId(
'saved-views-save-changes-item-update',
);
await waitFor(() => expectRowUsable(update), MENU_CHECK_TIMEOUT);
await userEvent.click(update);
await waitFor(() => expect(isDirty()).toBe(false));
await userEvent.click(screen.getByTestId('add-column'));

View File

@@ -9,8 +9,11 @@ import { DataSource } from 'types/common/queryBuilder';
import SavedViewsHeader from '../SavedViewsHeader';
import {
expectRowUsable,
expectUsable,
explorerUrl,
makeView,
MENU_CHECK_TIMEOUT,
queryWith,
renderWithExplorerProviders,
urlParam,
@@ -67,9 +70,21 @@ async function openDirtyView(
return rendered;
}
const SAVE_MENU_ITEM_TEST_IDS: Record<string, string> = {
'Save as new view': 'saved-views-save-changes-item-save-as-new',
'Update selected view': 'saved-views-save-changes-item-update',
};
async function chooseFromSaveMenu(item: string): Promise<void> {
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
await userEvent.click(await screen.findByRole('menuitem', { name: item }));
const menuItem = await screen.findByTestId(SAVE_MENU_ITEM_TEST_IDS[item]);
await waitFor(() => expectRowUsable(menuItem), MENU_CHECK_TIMEOUT);
await userEvent.click(menuItem);
}
async function clickCreate(): Promise<void> {
await waitFor(() => expectUsable(screen.getByTestId('saved-views-create')));
await userEvent.click(screen.getByTestId('saved-views-create'));
}
async function submitName(name: string): Promise<void> {
@@ -87,7 +102,7 @@ describe('SavedViewsHeader saving', () => {
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
const { history } = renderHeader(explorerUrl(PATH, {}));
await userEvent.click(screen.getByTestId('saved-views-create'));
await clickCreate();
expect(within(modal()).getByText('Create new view')).toBeInTheDocument();
expect(screen.getByTestId('save-view-submit')).toHaveAttribute(
@@ -130,7 +145,7 @@ describe('SavedViewsHeader saving', () => {
const requests = mockSavedViewsApi([], { createStatus: 500 });
const { history } = renderHeader(explorerUrl(PATH, {}));
await userEvent.click(screen.getByTestId('saved-views-create'));
await clickCreate();
await submitName('Slow checkout');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
@@ -145,7 +160,7 @@ describe('SavedViewsHeader saving', () => {
const requests = mockSavedViewsApi([]);
renderHeader(explorerUrl(PATH, {}));
await userEvent.click(screen.getByTestId('saved-views-create'));
await clickCreate();
await userEvent.type(screen.getByTestId('save-view-name'), 'Draft');
await userEvent.click(screen.getByTestId('save-view-cancel'));

View File

@@ -8,6 +8,7 @@ import ROUTES from 'constants/routes';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
expectUsable,
explorerUrl,
makeView,
renderWithExplorerProviders,
@@ -84,9 +85,9 @@ describe('SavedViewsPanel list', () => {
const { history } = renderPanel();
await screen.findByTestId('saved-views-list');
await userEvent.click(
within(row('Slow spans')).getByTestId('saved-views-row-name'),
);
const name = within(row('Slow spans')).getByTestId('saved-views-row-name');
await waitFor(() => expectUsable(name));
await userEvent.click(name);
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBe(
@@ -161,7 +162,9 @@ describe('SavedViewsPanel list', () => {
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
const { history } = renderPanel();
await userEvent.click(await screen.findByTestId('saved-views-list-create'));
const create = await screen.findByTestId('saved-views-list-create');
await waitFor(() => expectUsable(create));
await userEvent.click(create);
await userEvent.type(screen.getByTestId('save-view-name'), 'First view');
await userEvent.click(screen.getByTestId('save-view-submit'));

View File

@@ -9,8 +9,10 @@ import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
expectRowUsable,
explorerUrl,
makeView,
MENU_CHECK_TIMEOUT,
renderWithExplorerProviders,
urlParam,
viewUrl,
@@ -58,13 +60,22 @@ const row = (name: string): HTMLElement =>
.getAllByTestId('saved-views-row')
.find((element) => element.textContent?.includes(name)) as HTMLElement;
const ROW_MENU_ITEM_TEST_IDS: Record<string, string> = {
'Copy link': 'saved-views-row-menu-item-copyLink',
'Copy ID': 'saved-views-row-menu-item-copyId',
'Edit details': 'saved-views-row-menu-item-editDetails',
Delete: 'saved-views-row-menu-item-delete',
};
async function chooseFromRowMenu(
viewName: string,
item: string,
): Promise<void> {
await screen.findByTestId('saved-views-list');
fireEvent.click(within(row(viewName)).getByTestId('saved-views-row-menu'));
fireEvent.click(await screen.findByRole('menuitem', { name: item }));
const menuItem = await screen.findByTestId(ROW_MENU_ITEM_TEST_IDS[item]);
await waitFor(() => expectRowUsable(menuItem), MENU_CHECK_TIMEOUT);
fireEvent.click(menuItem);
}
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
@@ -76,21 +87,38 @@ describe('SavedViewsPanel row menu', () => {
jest.mocked(toast.success).mockClear();
});
it('offers copy link, edit details and delete', async () => {
it('offers copy link, copy id, edit details and delete', async () => {
mockSavedViewsApi([errors]);
renderPanelWithHeader();
await screen.findByTestId('saved-views-list');
fireEvent.click(within(row('Errors')).getByTestId('saved-views-row-menu'));
const items = await screen.findAllByRole('menuitem');
expect(items.map((item) => item.textContent)).toStrictEqual([
await screen.findByTestId(ROW_MENU_ITEM_TEST_IDS['Copy link']);
const items = Object.values(ROW_MENU_ITEM_TEST_IDS).map(
(testId) => screen.getByTestId(testId).textContent,
);
expect(items).toStrictEqual([
'Copy link',
'Copy ID',
'Edit details',
'Delete',
]);
});
it('copies the view id', async () => {
mockSavedViewsApi([errors, slow]);
renderPanelWithHeader();
await chooseFromRowMenu('Slow spans', 'Copy ID');
expect(mockCopy).toHaveBeenCalledWith(slow.id);
expect(toast.success).toHaveBeenCalledWith(
'View ID copied',
expect.anything(),
);
});
it('copies a link that opens the view with its query and tab', async () => {
mockSavedViewsApi([errors, slow]);
renderPanelWithHeader();

View File

@@ -1,4 +1,5 @@
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
@@ -13,6 +14,7 @@ export interface Requests {
}
// Mutates `views` on create, update and delete, so a refetch sees the change.
// Grants every permission; a later server.use overrides it.
export function mockSavedViewsApi(
views: SavedviewtypesSavedViewDTO[],
{
@@ -30,6 +32,7 @@ export function mockSavedViewsApi(
const requests: Requests = { created: [], updated: [], deleted: [] };
let failedLists = 0;
server.use(
setupAuthzAdmin(),
rest.get(API, (req, res, ctx) => {
if (failedLists < listFailures) {
failedLists += 1;

View File

@@ -251,3 +251,20 @@ export function renderWithExplorerProviders(
export function urlParam(history: MemoryHistory, key: string): string | null {
return new URLSearchParams(history.location.search).get(key);
}
// The ui Button marks a pending check with aria-busy and a block with
// aria-disabled, never the disabled attribute.
export function expectUsable(element: HTMLElement): void {
expect(element).not.toHaveAttribute('aria-busy');
expect(element).not.toHaveAttribute('aria-disabled', 'true');
}
// A menu checks its rows on first open; on a loaded CI runner that outlasts
// waitFor's 1s default.
export const MENU_CHECK_TIMEOUT = { timeout: 5000 };
// A menu row waiting on its check is data-loading, not yet data-disabled.
export function expectRowUsable(row: HTMLElement): void {
expect(row).not.toHaveAttribute('data-loading');
expect(row).not.toHaveAttribute('data-disabled');
}

View File

@@ -1,9 +1,13 @@
import { Link, PenLine, Trash2 } from '@signozhq/icons';
import { Copy, Link, PenLine, Trash2 } from '@signozhq/icons';
import {
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import {
buildSavedViewDeletePermission,
buildSavedViewUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewRowActionConfig, SaveViewModalMode } from './types';
@@ -30,6 +34,8 @@ export const MY_VIEW_NAME = 'My view';
export const SAVED_VIEW_LOAD_FAILED_NAME = 'View failed to load';
export const SAVED_VIEW_FORBIDDEN_NAME = 'No view access';
export const SAVED_VIEWS_LIST_LOADING_ROWS = 4;
export const SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS = 1000;
@@ -47,13 +53,20 @@ export const SAVE_VIEW_MODAL_TITLE: Record<SaveViewModalMode, string> = {
export const SAVED_VIEW_ROW_ACTIONS: SavedViewRowActionConfig[] = [
{ key: 'copyLink', label: 'Copy link', icon: Link },
{ key: 'editDetails', label: 'Edit details', icon: PenLine },
{ key: 'copyId', label: 'Copy ID', icon: Copy },
{
key: 'editDetails',
label: 'Edit details',
icon: PenLine,
buildPermission: buildSavedViewUpdatePermission,
},
{
key: 'delete',
label: 'Delete',
icon: Trash2,
danger: true,
hasDividerBefore: true,
buildPermission: buildSavedViewDeletePermission,
},
];

View File

@@ -6,7 +6,10 @@ import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { DataSource } from 'types/common/queryBuilder';
import { toAPIError } from 'utils/errorUtils';
import { SAVED_VIEW_OPTIONS_DATA_SOURCE } from '../constants';
import { UseActiveSavedViewResult } from '../types';
@@ -23,11 +26,19 @@ export function useActiveSavedView(
dataSource: optionsDataSource ?? DataSource.LOGS,
});
const { data, isLoading, isError } = useGetSavedView(
{ id: viewKey },
{ query: { enabled: !!viewKey } },
const { isLoading: isCheckingRead, deniedPermissions } = useAuthZ(
[buildSavedViewReadPermission(viewKey)],
{ enabled: !!viewKey },
);
const view = viewKey ? data?.data : undefined;
const isReadDenied = !!viewKey && deniedPermissions.length > 0;
const canFetch = !!viewKey && !isCheckingRead && !isReadDenied;
const { data, isLoading, isError, error } = useGetSavedView(
{ id: viewKey },
{ query: { enabled: canFetch } },
);
const view = canFetch ? data?.data : undefined;
// The query builder stages the url query a render later. Until the ids
// match, keep the last answer for this view; another view starts clean.
@@ -68,10 +79,17 @@ export function useActiveSavedView(
lastResult.current = { viewId: view?.id, hasUnsavedChanges };
}, [view?.id, hasUnsavedChanges]);
const errorMessage =
viewKey && isError && error
? toAPIError(error, '').getErrorMessage() || undefined
: undefined;
return {
view,
isLoading: !!viewKey && isLoading,
isError: !!viewKey && isError,
isLoading: !!viewKey && (isCheckingRead || isLoading),
isError: !!viewKey && (isError || isReadDenied),
isReadDenied,
errorMessage,
hasUnsavedChanges,
};
}

View File

@@ -59,6 +59,14 @@ export function useSavedViewRowActions(
[pathname, copyToClipboard],
);
const copyViewId = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
copyToClipboard(view.id);
toast.success('View ID copied', { position: SAVED_VIEW_TOAST_POSITION });
},
[copyToClipboard],
);
const renameView = useCallback(
async (
view: SavedviewtypesSavedViewDTO,
@@ -101,5 +109,12 @@ export function useSavedViewRowActions(
[deleteSavedView, source, queryClient],
);
return { copyViewLink, renameView, deleteView, isRenaming, isDeleting };
return {
copyViewLink,
copyViewId,
renameView,
deleteView,
isRenaming,
isDeleting,
};
}

View File

@@ -0,0 +1,329 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { rest, type RequestHandler } from 'msw';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import remove from 'api/browser/localstorage/remove';
import {
type AuthtypesTransactionDTO,
type SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
type SavedviewtypesUpdatableSavedViewDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SIDEBAR_TOOLTIP_DELAY_MS } from 'components/QuickFilters/QuickFiltersLayout/constants';
import { LOCALSTORAGE } from 'constants/localStorage';
import {
AUTHZ_CHECK_URL,
authzMockResponse,
} from 'lib/authz/utils/authz-test-utils';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { accessFor } from '@/storybook/access/access';
import {
choiceControl,
countControl,
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import type { MockControlValues, StoryMocks } from '@/storybook/controls/types';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import { authzMocks, type AuthzArgs } from '@/storybook/globals/authzMocks';
import { respondWith } from '@/storybook/runtime/responseState';
import {
createdSavedView,
OPEN_VIEW_ERRORS,
OPEN_VIEWS,
type OpenView,
SAVED_VIEW_SOURCES,
SAVED_VIEWS_MAX,
savedViewById,
savedViewResponse,
savedViewRoute,
savedViewsListResponse,
type SavedViewSource,
unreadableViewId,
} from './__story_mockdata__/savedViews';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
import type { SavedViewRowActionKey } from '../types';
const API = 'http://localhost/api/v2/saved_views';
const LIST = 'Saved Views · list';
const VIEW = 'Saved Views · open view';
const controls = {
source: choiceControl<SavedViewSource>('Source', {
group: VIEW,
description: 'Which explorer the views belong to.',
options: SAVED_VIEW_SOURCES,
value: SavedviewtypesSourceDTO.traces,
}),
openView: choiceControl<OpenView>('Open view', {
group: VIEW,
description:
'The `viewKey` the explorer url carries. `unsaved-changes` stages a query that differs from the view, which swaps in the ✓ save menu; `forbidden`, `failed` and `not-found` are the 403, 500 and 404 the view fetch answers with; each shows View failed to load.',
options: OPEN_VIEWS,
value: 'none',
}),
unreadableView: toggleControl('Unreadable view', {
group: VIEW,
description:
'Denies `read` on the first view created by others alone, on top of the Access controls: its row greys out, and with Open view on `created-by-others` the header shows No view access.',
value: false,
}),
createdByMe: countControl('Created by me', {
group: LIST,
value: 3,
max: SAVED_VIEWS_MAX,
}),
createdByOthers: countControl('Created by others', {
group: LIST,
description: 'Both at 0 is the empty state, with its Create view button.',
value: 4,
max: SAVED_VIEWS_MAX,
}),
};
type SavedViewsValues = MockControlValues<typeof controls>;
type WrittenView = Pick<SavedviewtypesSavedViewDTO, 'spec' | 'source'>;
const viewDenied = (
transaction: AuthtypesTransactionDTO,
deniedId: string,
): boolean =>
transaction.relation === 'read' &&
transaction.object.resource.kind === 'saved-view' &&
transaction.object.selector === deniedId;
const definition = defineStoryMocks({
controls,
handlers: (
values: SavedViewsValues & AuthzArgs,
response,
): RequestHandler[] => {
const { source, openView, createdByMe, createdByOthers } = values;
// What the panel writes stays in this handler set until a control changes.
const created: SavedviewtypesSavedViewDTO[] = [];
const updated = new Map<string, WrittenView>();
const deleted = new Set<string>();
const current = (
view: SavedviewtypesSavedViewDTO,
): SavedviewtypesSavedViewDTO => ({ ...view, ...updated.get(view.id) });
const findView = (id: string): SavedviewtypesSavedViewDTO | undefined => {
const view = created.find((item) => item.id === id) ?? savedViewById(id);
return view && !deleted.has(id) ? current(view) : undefined;
};
const openError = OPEN_VIEW_ERRORS[openView];
return [
rest.post(API, async (req, res, ctx) => {
const body = (await req.json()) as SavedviewtypesUpdatableSavedViewDTO;
const id = `storybook-${source}-created-${created.length + 1}`;
created.push(createdSavedView(source, id, body.spec));
return res(ctx.status(201), ctx.json({ status: 'success', data: { id } }));
}),
rest.get(
API,
response.json(() => {
const list = savedViewsListResponse(source, createdByMe, createdByOthers);
return {
...list,
data: [...(list.data ?? []), ...created]
.filter((view) => !deleted.has(view.id))
.map(current),
};
}),
),
rest.get(`${API}/:id`, (req, res, ctx) => {
if (openError) {
return res(ctx.status(openError.status), ctx.json(openError.body));
}
const view = findView(String(req.params.id));
if (!view) {
return res(ctx.status(404), ctx.json(OPEN_VIEW_ERRORS['not-found']?.body));
}
return response.json(() => savedViewResponse(view))(req, res, ctx);
}),
rest.put(`${API}/:id`, async (req, res, ctx) => {
const body = (await req.json()) as SavedviewtypesUpdatableSavedViewDTO;
updated.set(String(req.params.id), {
spec: body.spec,
source: body.source,
});
return res(ctx.status(204));
}),
rest.delete(`${API}/:id`, (req, res, ctx) => {
deleted.add(String(req.params.id));
return res(ctx.status(204));
}),
...(values.unreadableView
? [
rest.post(
AUTHZ_CHECK_URL,
respondWith(values.authzState, async (req) => {
const payload = (await req.json()) as AuthtypesTransactionDTO[];
const granted = accessFor(
values.access,
values.permissions,
values.revoked,
);
const deniedId = unreadableViewId(source);
return authzMockResponse(
payload,
payload.map(
(transaction) =>
!viewDenied(transaction, deniedId) && granted.allows(transaction),
),
);
}),
),
]
: []),
];
},
config: ({ source, openView }) => ({
route: savedViewRoute(source, openView),
}),
effect: () => {
remove(LOCALSTORAGE.LAST_USED_SAVED_VIEWS);
},
});
/**
* The per-view read denial answers `authz/check` in place of the global access
* mocks, so it reads their controls as well as its own to answer every other
* check the way they would.
*/
export const savedViewsMocks: StoryMocks<typeof controls> = {
...definition,
read: (args) => ({ ...authzMocks.read(args), ...definition.read(args) }),
};
/** The sidebar slot of an explorer without quick filters. */
export const sidebarCanvas = withCanvas({
width: 280,
height: 640,
padding: 0,
display: 'flex',
flexDirection: 'column',
borderRight: '1px solid var(--l1-border)',
});
/**
* The header above the pinned list. `onOpenViews` adds All views, which the
* header only shows in an explorer whose quick filters hide the list.
*/
export function SavedViewsSidebar({
source,
onOpenViews,
}: {
source: SavedViewSource;
onOpenViews?: () => void;
}): JSX.Element {
return (
<TooltipProvider delay={SIDEBAR_TOOLTIP_DELAY_MS}>
<SavedViewsHeader source={source} onOpenViews={onOpenViews} />
<SavedViewsPanel source={source} />
</TooltipProvider>
);
}
/** The panel fetches the list and checks every row's permissions before it settles. */
export const UNTIL_LOADED = { timeout: 15_000 };
/**
* Re-queried on every attempt: a button whose check settles is a new node, so a
* reference taken while it was pending never changes.
*/
type FindElement = () => HTMLElement;
export const byTestId =
(testId: string): FindElement =>
(): HTMLElement =>
screen.getByTestId(testId);
/** A check still in flight: `aria-busy` on a button, `data-loading` on a menu row. */
const isPending = (element: HTMLElement): boolean =>
element.getAttribute('aria-busy') === 'true' ||
element.hasAttribute('data-loading');
const isDisabled = (element: HTMLElement): boolean =>
element.hasAttribute('disabled') || element.hasAttribute('data-disabled');
export const expectDisabled = (
find: FindElement,
disabled = true,
): Promise<void> =>
waitFor(async () => {
const element = find();
await expect(isPending(element)).toBe(false);
await expect(isDisabled(element)).toBe(disabled);
}, UNTIL_LOADED);
export const expectPending = (find: FindElement): Promise<void> =>
waitFor(() => expect(isPending(find())).toBe(true), UNTIL_LOADED);
export const findRows = (canvasElement: HTMLElement): Promise<HTMLElement[]> =>
within(canvasElement).findAllByTestId(
'saved-views-row',
undefined,
UNTIL_LOADED,
);
export const rowName =
(row: HTMLElement): FindElement =>
(): HTMLElement =>
within(row).getByTestId('saved-views-row-name');
export const openRowMenu = async (row: HTMLElement): Promise<void> => {
await userEvent.click(within(row).getByTestId('saved-views-row-menu'));
await screen.findByRole('menu', undefined, UNTIL_LOADED);
};
export const rowMenuItem = (key: SavedViewRowActionKey): FindElement =>
byTestId(`saved-views-row-menu-item-${key}`);
export const openSaveChangesMenu = async (
canvasElement: HTMLElement,
): Promise<void> => {
const trigger = await waitFor(async () => {
const button = within(canvasElement).getByTestId('saved-views-save-changes');
await expect(button).not.toHaveAttribute('aria-busy');
return button;
}, UNTIL_LOADED);
await userEvent.click(trigger);
await screen.findByRole('menu', undefined, UNTIL_LOADED);
};
export const saveChangesItem = (key: 'save-as-new' | 'update'): FindElement =>
byTestId(`saved-views-save-changes-item-${key}`);

View File

@@ -0,0 +1,80 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import SavedViewsPanel from '../SavedViewsPanel';
import {
findRows,
openRowMenu,
openSaveChangesMenu,
SavedViewsSidebar,
savedViewsMocks,
sidebarCanvas,
} from './SavedViews.stories.mocks';
type SavedViewsArgs = PageStoryArgs<typeof savedViewsMocks>;
const componentStory = storyMocks(savedViewsMocks);
/**
* The saved views sidebar of an explorer without quick filters: the header with
* the open view and its actions, above the list of views created by the user
* and by others. Every action is gated on the `saved-view` authz permissions
* (list, create, and read, update and delete per view).
*
* Route: the explorer of the Source control, with the Open view control's
* `viewKey`.
*/
const meta = {
title: 'Components/Saved Views/Overview',
tags: ['authz', 'play'],
component: SavedViewsPanel,
decorators: [sidebarCanvas],
render: ({ source }): JSX.Element => <SavedViewsSidebar source={source} />,
...componentStory,
parameters: { ...componentStory.parameters },
} satisfies Meta<SavedViewsArgs>;
export default meta;
type Story = StoryObj<SavedViewsArgs>;
/** Traces views created by the user and by others, with no view open. */
export const Default: Story = {};
/** A workspace with no views yet: the empty state offers Create view. */
export const Empty: Story = {
args: { createdByMe: 0, createdByOthers: 0 },
};
/**
* A view open with a query that differs from what it saved: the name carries
* the unsaved dot, and ✓ and discard replace Clear view.
*/
export const UnsavedChanges: Story = {
args: { openView: 'unsaved-changes' },
};
/**
* The first row's menu. Edit details and Delete follow `update` and `delete` on
* that view, so revoking either disables its item.
*/
export const RowMenu: Story = {
play: async ({ canvasElement }): Promise<void> => {
const [row] = await findRows(canvasElement);
await openRowMenu(row);
},
};
/**
* The ✓ menu on a view with unsaved changes. Save as new view follows `create`
* and Update selected view follows `update` on the open view.
*/
export const SaveChangesMenu: Story = {
args: { openView: 'unsaved-changes' },
play: async ({ canvasElement }): Promise<void> => {
await openSaveChangesMenu(canvasElement);
},
};

View File

@@ -0,0 +1,309 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import type {
GetSavedView200,
ListSavedViews200,
RenderErrorResponseDTO,
SavedviewtypesSavedViewDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import {
initialQueriesMap,
initialQueryMeterWithType,
PANEL_TYPES,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
import { toSavedViewSpec } from 'container/SavedViews/utils/toSavedViewSpec';
import { createAppContextMock } from 'tests/fixtures/appContextMock';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { USER_ROLES } from 'types/roles';
export const SAVED_VIEW_SOURCES = [
SavedviewtypesSourceDTO.traces,
SavedviewtypesSourceDTO.logs,
SavedviewtypesSourceDTO.metrics,
SavedviewtypesSourceDTO.meter,
] as const;
export type SavedViewSource = (typeof SAVED_VIEW_SOURCES)[number];
export const OPEN_VIEWS = [
'none',
'created-by-me',
'created-by-others',
'unsaved-changes',
'forbidden',
'failed',
'not-found',
] as const;
export type OpenView = (typeof OPEN_VIEWS)[number];
export const SAVED_VIEWS_MAX = 8;
/** The user the story app context signs in as, so "Created by me" follows it. */
const STORY_USER_EMAIL = createAppContextMock(USER_ROLES.ADMIN).user.email;
const OTHER_AUTHORS = ['priya@signoz.io', 'marco@signoz.io', 'lena@signoz.io'];
const MY_VIEW_NAMES = [
'Checkout errors',
'Slow payment calls',
'Cart service, production',
'Auth retries',
'Inventory sync',
'Search p99 regressions',
'Webhook failures',
'Cold starts after deploy',
];
const OTHER_VIEW_NAMES = [
'On-call triage',
'Frontend 5xx',
'Shipping latency by region',
'Kafka consumer lag',
'Staging smoke tests',
'Recommendation timeouts',
'Ad service sampling',
'Email queue backlog',
];
const EXPRESSIONS = [
"service.name = 'checkout' AND has_error = true",
"service.name = 'payments'",
"service.name = 'cart' AND deployment.environment = 'production'",
"service.name = 'auth'",
"service.name IN ('inventory', 'catalog')",
"service.name = 'search'",
"service.name = 'webhooks' AND has_error = true",
"deployment.environment = 'production'",
];
const BASE_QUERY: Record<SavedViewSource, Query> = {
traces: initialQueriesMap.traces,
logs: initialQueriesMap.logs,
metrics: initialQueriesMap.metrics,
meter: initialQueryMeterWithType,
};
const EXPLORER_ROUTE: Record<SavedViewSource, string> = {
traces: ROUTES.TRACES_EXPLORER,
logs: ROUTES.LOGS_EXPLORER,
metrics: ROUTES.METRICS_EXPLORER_EXPLORER,
meter: ROUTES.METER_EXPLORER,
};
const queryWith = (source: SavedViewSource, expression: string): Query => {
const base = BASE_QUERY[source];
return {
...base,
builder: {
...base.builder,
queryData: [
{
...base.builder.queryData[0],
filter: { expression },
aggregations: [{ expression: 'count()' }],
},
],
},
} as Query;
};
type Author = 'mine' | 'others';
const viewId = (
source: SavedViewSource,
author: Author,
index: number,
): string => `storybook-${source}-${author}-${index + 1}`;
/** The view other people made that the per-view read denial applies to. */
export const unreadableViewId = (source: SavedViewSource): string =>
viewId(source, 'others', 0);
const savedView = (
source: SavedViewSource,
author: Author,
index: number,
): SavedviewtypesSavedViewDTO => {
const displayName = (author === 'mine' ? MY_VIEW_NAMES : OTHER_VIEW_NAMES)[
index
];
const createdBy =
author === 'mine'
? STORY_USER_EMAIL
: OTHER_AUTHORS[index % OTHER_AUTHORS.length];
const id = viewId(source, author, index);
return {
id,
name: id,
source,
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
createdAt: '2026-09-02T08:30:00Z',
createdBy,
updatedAt: `2026-10-${String(14 - (index % 10)).padStart(2, '0')}T11:42:00Z`,
updatedBy: createdBy,
spec: toSavedViewSpec({
query: queryWith(
source,
EXPRESSIONS[(index + (author === 'mine' ? 0 : 3)) % EXPRESSIONS.length],
),
panelType: PANEL_TYPES.LIST,
displayName,
}),
};
};
const savedViews = (
source: SavedViewSource,
author: Author,
count: number,
): SavedviewtypesSavedViewDTO[] =>
Array.from({ length: Math.min(count, SAVED_VIEWS_MAX) }, (_, index) =>
savedView(source, author, index),
);
export const savedViewsListResponse = (
source: SavedViewSource,
createdByMe: number,
createdByOthers: number,
): ListSavedViews200 => ({
status: 'success',
data: [
...savedViews(source, 'mine', createdByMe),
...savedViews(source, 'others', createdByOthers),
],
});
/** Every view the workspace has, whatever the list control trims it to. */
export const savedViewById = (
id: string,
): SavedviewtypesSavedViewDTO | undefined =>
SAVED_VIEW_SOURCES.flatMap((source) => [
...savedViews(source, 'mine', SAVED_VIEWS_MAX),
...savedViews(source, 'others', SAVED_VIEWS_MAX),
]).find((view) => view.id === id);
/** A view the panel itself created, owned by the story user. */
export const createdSavedView = (
source: SavedViewSource,
id: string,
spec: SavedviewtypesSavedViewDTO['spec'],
): SavedviewtypesSavedViewDTO => ({
...savedView(source, 'mine', 0),
id,
name: id,
createdAt: '2026-10-16T09:00:00Z',
updatedAt: '2026-10-16T09:00:00Z',
spec,
});
export const savedViewResponse = (
view: SavedviewtypesSavedViewDTO,
): GetSavedView200 => ({ status: 'success', data: view });
export const OPEN_VIEW_ERRORS: Partial<
Record<OpenView, { status: number; body: RenderErrorResponseDTO }>
> = {
forbidden: {
status: 403,
body: {
status: 'error',
error: {
code: 'forbidden',
message: 'You do not have access to this saved view',
errors: [],
suggestions: [],
type: 'forbidden',
},
},
},
failed: {
status: 500,
body: {
status: 'error',
error: {
code: 'internal',
message: 'Failed to load the saved view',
errors: [],
suggestions: [],
type: 'internal',
},
},
},
'not-found': {
status: 404,
body: {
status: 'error',
error: {
code: 'not_found',
message: 'Saved view not found',
errors: [],
suggestions: [],
type: 'not_found',
},
},
},
};
const openViewId = (source: SavedViewSource, open: OpenView): string | null => {
if (open === 'none') {
return null;
}
if (open === 'created-by-others' || open === 'forbidden') {
return viewId(source, 'others', 0);
}
if (open === 'not-found') {
return `storybook-${source}-deleted`;
}
return viewId(source, 'mine', 0);
};
/**
* The explorer url with a view open, its params written the way
* `redirectWithQueryBuilderData` writes them. `panelTypes` is left out: the
* query builder seeds its panel type from the raw, JSON-quoted param, which no
* saved panel type equals, and without it the header compares against `list`.
*/
export const savedViewRoute = (
source: SavedViewSource,
open: OpenView,
): string => {
const params = new URLSearchParams({ relativeTime: '15m' });
const id = openViewId(source, open);
if (id) {
const view = savedViewById(id);
const query = view
? getSavedViewQuery(view)
: queryWith(source, EXPRESSIONS[0]);
const staged =
open === 'unsaved-changes'
? queryWith(source, `${EXPRESSIONS[0]} AND duration_nano > 500000000`)
: query;
params.set(
QueryParams.compositeQuery,
encodeURIComponent(
JSON.stringify({ ...staged, id: `storybook-staged-${open}` }),
),
);
params.set(QueryParams.viewKey, JSON.stringify(id));
}
return `${EXPLORER_ROUTE[source]}?${params.toString()}`;
};

View File

@@ -0,0 +1,220 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { unreadableViewId } from '../__story_mockdata__/savedViews';
import {
byTestId,
expectDisabled,
expectPending,
findRows,
openRowMenu,
openSaveChangesMenu,
rowMenuItem,
rowName,
saveChangesItem,
SavedViewsSidebar,
savedViewsMocks,
sidebarCanvas,
UNTIL_LOADED,
} from '../SavedViews.stories.mocks';
import SavedViewsPanel from '../../SavedViewsPanel';
type SavedViewsArgs = PageStoryArgs<typeof savedViewsMocks>;
const componentStory = storyMocks(savedViewsMocks);
/**
* The saved views sidebar with one permission missing at a time. Every story
* here is an admin denied exactly what its name says. `list` and `create` are
* workspace-wide; `read`, `update` and `delete` are checked per view, so a row
* menu only checks its view once it is opened.
*
* Route: the explorer of the Source control, with the Open view control's
* `viewKey`.
*/
const meta = {
title: 'Components/Saved Views/Authz',
tags: ['authz', 'play'],
component: SavedViewsPanel,
decorators: [sidebarCanvas],
render: ({ source }): JSX.Element => <SavedViewsSidebar source={source} />,
...componentStory,
parameters: { ...componentStory.parameters },
} satisfies Meta<SavedViewsArgs>;
export default meta;
type Story = StoryObj<SavedViewsArgs>;
const LIST = 'list:saved-view';
const CREATE = 'create:saved-view';
const UPDATE = 'update:saved-view';
const DELETE = 'delete:saved-view';
/**
* The panel shows the permission callout in place of the list, and All views,
* in an explorer whose quick filters hide the list, is disabled.
*/
export const NoList: Story = {
args: { revoked: [LIST] },
render: ({ source }): JSX.Element => (
<SavedViewsSidebar source={source} onOpenViews={(): void => {}} />
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectDisabled(byTestId('saved-views-open'));
await waitFor(
() =>
expect(canvas.getByTestId('saved-views-panel')).toHaveTextContent(
/is not authorized to perform list/,
),
UNTIL_LOADED,
);
await expect(canvas.queryByTestId('saved-views-search')).toBeNull();
},
};
/** An empty workspace with both ways to create a view disabled: + and Create view. */
export const NoCreate: Story = {
args: { revoked: [CREATE], createdByMe: 0, createdByOthers: 0 },
play: async () => {
await expectDisabled(byTestId('saved-views-create'));
await expectDisabled(byTestId('saved-views-list-create'));
},
};
/** The ✓ menu on unsaved changes: Save as new view disabled, Update selected view open. */
export const NoCreateSaveMenu: Story = {
args: { revoked: [CREATE], openView: 'unsaved-changes' },
play: async ({ canvasElement }) => {
await openSaveChangesMenu(canvasElement);
await expectDisabled(saveChangesItem('save-as-new'));
await expectDisabled(saveChangesItem('update'), false);
},
};
/**
* `read` denied on one view made by someone else: its name is disabled and its
* hover card never opens, while every other view still opens.
*/
export const NoRead: Story = {
args: { unreadableView: true },
play: async ({ canvasElement, args }) => {
const rows = await findRows(canvasElement);
const deniedId = unreadableViewId(args.source);
await Promise.all(
rows.map((row) =>
expectDisabled(rowName(row), row.getAttribute('data-view-id') === deniedId),
),
);
},
};
/** The unreadable view open from its link: the header names it No view access. */
export const NoReadOpenView: Story = {
args: { unreadableView: true, openView: 'created-by-others' },
play: async ({ canvasElement }) => {
await expect(
await within(canvasElement).findByText(
'No view access',
undefined,
UNTIL_LOADED,
),
).toBeVisible();
},
};
/** A row's menu: Edit details disabled; Copy link, Copy ID and Delete open. */
export const NoUpdate: Story = {
args: { revoked: [UPDATE] },
play: async ({ canvasElement }) => {
const [row] = await findRows(canvasElement);
await openRowMenu(row);
await expectDisabled(rowMenuItem('editDetails'));
await expectDisabled(rowMenuItem('copyLink'), false);
await expectDisabled(rowMenuItem('copyId'), false);
await expectDisabled(rowMenuItem('delete'), false);
},
};
/** The ✓ menu on unsaved changes: Update selected view disabled, Save as new view open. */
export const NoUpdateSaveMenu: Story = {
args: { revoked: [UPDATE], openView: 'unsaved-changes' },
play: async ({ canvasElement }) => {
await openSaveChangesMenu(canvasElement);
await expectDisabled(saveChangesItem('update'));
await expectDisabled(saveChangesItem('save-as-new'), false);
},
};
/** A row's menu: Delete disabled, Edit details still open. */
export const NoDelete: Story = {
args: { revoked: [DELETE] },
play: async ({ canvasElement }) => {
const [row] = await findRows(canvasElement);
await openRowMenu(row);
await expectDisabled(rowMenuItem('delete'));
await expectDisabled(rowMenuItem('editDetails'), false);
},
};
/**
* Everything but listing and reading: + is disabled, and a row's menu has only
* Copy link and Copy ID left.
*/
export const ReadOnly: Story = {
args: { revoked: [CREATE, UPDATE, DELETE] },
play: async ({ canvasElement }) => {
await expectDisabled(byTestId('saved-views-create'));
const [row] = await findRows(canvasElement);
await expectDisabled(rowName(row), false);
await openRowMenu(row);
await expectDisabled(rowMenuItem('editDetails'));
await expectDisabled(rowMenuItem('delete'));
await expectDisabled(rowMenuItem('copyLink'), false);
await expectDisabled(rowMenuItem('copyId'), false);
},
};
/**
* The permission check never answering: + spins, and the list stays blank
* behind its `list` check.
*/
export const CheckLoading: Story = {
args: { authzState: 'loading' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectPending(byTestId('saved-views-create'));
await expect(canvas.queryByTestId('saved-views-search')).toBeNull();
},
};
/**
* The permission check failing rather than denying: the sidebar fails open,
* so the list renders and every action stays enabled for the API to refuse.
*/
export const CheckFailed: Story = {
args: { authzState: 'error' },
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }) => {
await expectDisabled(byTestId('saved-views-create'), false);
const [row] = await findRows(canvasElement);
await expectDisabled(rowName(row), false);
await openRowMenu(row);
await expectDisabled(rowMenuItem('editDetails'), false);
await expectDisabled(rowMenuItem('delete'), false);
},
};

View File

@@ -1,5 +1,6 @@
import { ReactElement, ReactNode } from 'react';
import type { Link } from '@signozhq/icons';
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
import {
SavedviewtypesSavedViewDTO,
SavedviewtypesSourceDTO,
@@ -41,6 +42,8 @@ export interface UseActiveSavedViewResult {
view: SavedviewtypesSavedViewDTO | undefined;
isLoading: boolean;
isError: boolean;
isReadDenied: boolean;
errorMessage: string | undefined;
hasUnsavedChanges: boolean;
}
@@ -70,6 +73,7 @@ export interface SaveViewModalProps {
}
export interface SaveChangesMenuProps {
viewId: string;
disabled: boolean;
onSaveAsNew: () => void;
onUpdate: () => void;
@@ -82,6 +86,7 @@ export interface SavedViewsIconButtonProps {
color?: 'secondary' | 'warning';
disabled?: boolean;
disabledTooltip?: ReactNode;
checks?: BrandedPermission[];
onClick?: () => void;
}
@@ -116,7 +121,11 @@ export interface SavedViewsListRowProps {
hover: SavedViewsListRowHoverProps;
}
export type SavedViewRowActionKey = 'copyLink' | 'editDetails' | 'delete';
export type SavedViewRowActionKey =
| 'copyLink'
| 'copyId'
| 'editDetails'
| 'delete';
export interface SavedViewRowActionConfig {
key: SavedViewRowActionKey;
@@ -124,10 +133,11 @@ export interface SavedViewRowActionConfig {
icon: typeof Link;
danger?: boolean;
hasDividerBefore?: boolean;
buildPermission?: (id: string) => BrandedPermission;
}
export interface PendingSavedViewAction {
key: Exclude<SavedViewRowActionKey, 'copyLink'>;
key: Exclude<SavedViewRowActionKey, 'copyLink' | 'copyId'>;
view: SavedviewtypesSavedViewDTO;
}
@@ -140,6 +150,7 @@ export interface SavedViewsRowButtonProps {
}
export interface SavedViewsRowMenuProps {
viewId: string;
onAction: (key: SavedViewRowActionKey) => void;
onOpen: () => void;
}
@@ -152,6 +163,7 @@ export interface DeleteSavedViewDialogProps {
export interface UseSavedViewRowActionsResult {
copyViewLink: (view: SavedviewtypesSavedViewDTO) => void;
copyViewId: (view: SavedviewtypesSavedViewDTO) => void;
renameView: (
view: SavedviewtypesSavedViewDTO,
displayName: string,

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,61 @@
import { renderHook } from '@testing-library/react';
import { initialQueriesMap } from 'constants/queryBuilder';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useActiveLog } from '../useActiveLog';
const mockRedirect = jest.fn();
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): unknown => ({
currentQuery: initialQueriesMap.logs,
redirectWithQueryBuilderData: mockRedirect,
}),
}));
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: (): URLSearchParams => new URLSearchParams(),
}));
describe('useActiveLog', () => {
beforeEach(() => {
mockRedirect.mockClear();
});
it('adds the chip field as a filter with its data type and redirects', () => {
const { result } = renderHook(() => useActiveLog());
result.current.onAddToQuery(
'service.name',
'frontend',
'=',
DataTypes.String,
);
expect(mockRedirect).toHaveBeenCalledTimes(1);
const query = mockRedirect.mock.calls[0][0] as Query;
const item = query.builder.queryData[0].filters?.items.at(-1);
expect(item?.key).toMatchObject({
key: 'service.name',
dataType: DataTypes.String,
type: '',
});
expect(item?.op).toBe('=');
expect(item?.value).toBe('frontend');
});
it('keeps the existing filter items ahead of the new one', () => {
const { result } = renderHook(() => useActiveLog());
result.current.onAddToQuery('k8s.pod.name', 'pod-1', '=');
const query = mockRedirect.mock.calls[0][0] as Query;
const items = query.builder.queryData[0].filters?.items ?? [];
expect(items).toHaveLength(
(initialQueriesMap.logs.builder.queryData[0].filters?.items.length ?? 0) + 1,
);
expect(items.at(-1)?.key?.key).toBe('k8s.pod.name');
});
});

View File

@@ -11,7 +11,6 @@ jest.mock('hooks/useIsTextSelected');
const mockOnSetActiveLog = jest.fn();
const mockOnClearActiveLog = jest.fn();
const mockOnAddToQuery = jest.fn();
const mockOnGroupByAttribute = jest.fn();
const mockIsTextSelected = jest.fn();
const mockLog: ILog = {
@@ -44,7 +43,6 @@ beforeEach(() => {
onSetActiveLog: mockOnSetActiveLog,
onClearActiveLog: mockOnClearActiveLog,
onAddToQuery: mockOnAddToQuery,
onGroupByAttribute: mockOnGroupByAttribute,
});
});
@@ -80,7 +78,6 @@ it('should toggle off when clicking the same active log', () => {
onSetActiveLog: mockOnSetActiveLog,
onClearActiveLog: mockOnClearActiveLog,
onAddToQuery: mockOnAddToQuery,
onGroupByAttribute: mockOnGroupByAttribute,
});
const { result } = renderHook(() => useLogDetailHandlers());

View File

@@ -25,5 +25,4 @@ export type UseActiveLog = {
operator: string,
dataType?: DataTypes,
) => void;
onGroupByAttribute: (fieldKey: string, dataType?: DataTypes) => Promise<void>;
};

View File

@@ -1,40 +1,16 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { QueryParams } from 'constants/query';
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
import { MetricsType } from 'container/MetricsApplication/constant';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { getFilterQueryData } from 'container/LogDetailedView/logAttributeActions.utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import { ILog } from 'types/api/logs/log';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
import { UseActiveLog } from './types';
export function getOldLogsOperatorFromNew(operator: string): string {
switch (operator) {
case OPERATORS['=']:
return OPERATORS.IN;
case OPERATORS['!=']:
return OPERATORS.NIN;
default:
return operator;
}
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export const useActiveLog = (): UseActiveLog => {
const queryClient = useQueryClient();
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const { notifications } = useNotifications();
const [activeLog, setActiveLog] = useState<ILog | null>(null);
@@ -58,121 +34,32 @@ export const useActiveLog = (): UseActiveLog => {
const onClearActiveLog = useCallback((): void => setActiveLog(null), []);
const onAddToQueryExplorer = useCallback(
async (
const onAddToQuery = useCallback(
(
fieldKey: string,
fieldValue: string,
operator: string,
dataType?: DataTypes,
fieldType?: MetricsType | undefined,
): Promise<void> => {
try {
const keysAutocompleteResponse = await queryClient.fetchQuery(
[QueryBuilderKeys.GET_AGGREGATE_KEYS, fieldKey],
async () =>
getAggregateKeys({
searchText: fieldKey,
aggregateOperator:
currentQuery.builder.queryData[0].aggregateOperator || '',
dataSource: currentQuery.builder.queryData[0].dataSource,
aggregateAttribute:
currentQuery.builder.queryData[0].aggregateAttribute?.key || '',
}),
);
): void => {
const nextQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item) =>
getFilterQueryData(item, { fieldKey, dataType }, fieldValue, operator),
),
},
};
const keysAutocomplete: BaseAutocompleteData[] =
keysAutocompleteResponse.payload?.attributeKeys || [];
const existAutocompleteKey = chooseAutocompleteFromCustomValue(
keysAutocomplete,
fieldKey,
dataType,
fieldType,
);
const currentOperator = getOperatorValue(operator);
const nextQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item) => ({
...item,
filters: {
...item.filters,
items: [
...(item.filters?.items || []),
{
id: uuid(),
key: existAutocompleteKey,
op: currentOperator,
value: fieldValue,
},
],
op: item.filters?.op || 'AND',
},
})),
},
};
redirectWithQueryBuilderData(nextQuery);
} catch {
notifications.error({ message: SOMETHING_WENT_WRONG });
}
redirectWithQueryBuilderData(nextQuery);
},
[currentQuery, notifications, queryClient, redirectWithQueryBuilderData],
);
const onGroupByAttribute = useCallback(
async (fieldKey: string, dataType?: DataTypes): Promise<void> => {
try {
const keysAutocompleteResponse = await queryClient.fetchQuery(
[QueryBuilderKeys.GET_AGGREGATE_KEYS, fieldKey],
// eslint-disable-next-line sonarjs/no-identical-functions
async () =>
getAggregateKeys({
searchText: fieldKey,
aggregateOperator:
currentQuery.builder.queryData[0].aggregateOperator || '',
dataSource: currentQuery.builder.queryData[0].dataSource,
aggregateAttribute:
currentQuery.builder.queryData[0].aggregateAttribute?.key || '',
}),
);
const keysAutocomplete: BaseAutocompleteData[] =
keysAutocompleteResponse.payload?.attributeKeys || [];
const existAutocompleteKey = chooseAutocompleteFromCustomValue(
keysAutocomplete,
fieldKey,
dataType,
);
const nextQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item) => ({
...item,
groupBy: [...item.groupBy, existAutocompleteKey],
})),
},
};
redirectWithQueryBuilderData(nextQuery);
} catch {
notifications.error({ message: SOMETHING_WENT_WRONG });
}
},
[currentQuery, notifications, queryClient, redirectWithQueryBuilderData],
[currentQuery, redirectWithQueryBuilderData],
);
return {
activeLog,
onSetActiveLog,
onClearActiveLog,
onAddToQuery: onAddToQueryExplorer,
onGroupByAttribute,
onAddToQuery,
};
};

View File

@@ -23,6 +23,11 @@ export default {
type: 'metaresource',
allowedVerbs: ['list', 'read', 'update'],
},
{
kind: 'saved-view',
type: 'metaresource',
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
},
{
kind: 'subscription',
type: 'metaresource',

View File

@@ -0,0 +1,15 @@
import type { BrandedPermission } from '../types';
import { buildPermission } from '../utils';
export const SavedViewListPermission = buildPermission('list', 'saved-view:*');
export const SavedViewCreatePermission = buildPermission(
'create',
'saved-view:*',
);
export const buildSavedViewReadPermission = (id: string): BrandedPermission =>
buildPermission('read', `saved-view:${id}`);
export const buildSavedViewUpdatePermission = (id: string): BrandedPermission =>
buildPermission('update', `saved-view:${id}`);
export const buildSavedViewDeletePermission = (id: string): BrandedPermission =>
buildPermission('delete', `saved-view:${id}`);

View File

@@ -1,24 +0,0 @@
import { QueryOperatorsMultiVal } from 'lib/logql/tokens';
type Keys = keyof typeof QueryOperatorsMultiVal;
type Values = (typeof QueryOperatorsMultiVal)[Keys];
interface GenerateFilterQueryParams {
fieldKey: string;
fieldValue: string;
type: Values;
}
export const generateFilterQuery = ({
fieldKey,
fieldValue,
type,
}: GenerateFilterQueryParams): string => {
let generatedQueryString = `${fieldKey} ${type} `;
if (typeof fieldValue === 'number') {
generatedQueryString += `(${fieldValue})`;
} else {
generatedQueryString += `('${fieldValue}')`;
}
return generatedQueryString;
};

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

@@ -217,8 +217,6 @@ function ListPanelRenderer({
log={logDetail.activeLog}
selectedTab={logDetail.selectedTab}
onClose={logDetail.onClose}
onAddToQuery={logDetail.onAddToQuery}
onClickActionItem={logDetail.onAddToQuery}
onNavigateLog={logDetail.onNavigateLog}
logs={logDetail.logs}
isListViewPanel

View File

@@ -18,7 +18,6 @@ export interface ListLogDetail {
selectedTab: VIEWS;
logs: ILog[];
onClose: () => void;
onAddToQuery: ReturnType<typeof useLogDetailHandlers>['onAddToQuery'];
onNavigateLog: (log: ILog) => void;
}
@@ -49,13 +48,8 @@ export function useListRowInteraction({
const isLogs = signal === TelemetrytypesSignalDTO.logs;
const isTraces = signal === TelemetrytypesSignalDTO.traces;
const {
activeLog,
onAddToQuery,
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
} = useLogDetailHandlers();
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
useLogDetailHandlers();
const logs = useMemo(
() => (isLogs ? rows.map(rawRowToILog) : []),
@@ -90,7 +84,6 @@ export function useListRowInteraction({
selectedTab: selectedTab ?? VIEW_TYPES.OVERVIEW,
logs,
onClose: handleCloseLogDetail,
onAddToQuery,
onNavigateLog: handleSetActiveLog,
}
: null,
@@ -100,7 +93,6 @@ export function useListRowInteraction({
selectedTab,
logs,
handleCloseLogDetail,
onAddToQuery,
handleSetActiveLog,
],
);

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

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

2
go.mod
View File

@@ -12,6 +12,7 @@ require (
github.com/SigNoz/signoz-otel-collector v0.144.6
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/bytedance/sonic v1.14.1
github.com/cespare/xxhash/v2 v2.3.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/dgraph-io/ristretto/v2 v2.3.0
@@ -111,7 +112,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect

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
}

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